Merge TwinCAT native array subscriptions (live-validated on the Elma PLCs)
v2-ci / build (push) Successful in 4m39s
v2-ci / unit-tests (push) Failing after 15m16s

This commit is contained in:
Joseph Doherty
2026-10-07 15:08:17 -04:00
18 changed files with 1058 additions and 109 deletions
+1 -1
View File
@@ -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) | | **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 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) | | **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). 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).
+1 -1
View File
@@ -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) | | **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 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) | | **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) ### Out of scope (named deferrals)
+5
View File
@@ -93,6 +93,11 @@ All tests gate on `TWINCAT_TARGET_NETID` (required) via `[TwinCATFact]` /
`BrowseSymbolsAsync` + system-symbol filtering `BrowseSymbolsAsync` + system-symbol filtering
- `TwinCATNativeNotificationTests` — `AddDeviceNotification` registration, - `TwinCATNativeNotificationTests` — `AddDeviceNotification` registration,
callback-delivery-to-`OnDataChange` wiring, unregister on unsubscribe 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 - `TwinCATDriverTests` — `IDriver` lifecycle
Capability surfaces whose contract is verified at the unit layer: `IDriver`, Capability surfaces whose contract is verified at the unit layer: `IDriver`,
+118 -20
View File
@@ -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 deployments where the AMS router has notification limits you can't raise — then
the driver falls through to the shared poll engine. 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 ## Single-connection-per-device
Each device's ADS client is lazily connected and serialized by a 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 - **CLI** — [Driver.TwinCAT.Cli.md](../Driver.TwinCAT.Cli.md) documents the
standalone read/write/browse/probe CLI for manual checks. 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 A TwinCAT tag becomes a **1-D OPC UA array node** (ValueRank 1, `ArrayDimensions = [N]`) when
`"isArray": true` and `"arrayLength": N` (N ≥ 1). The canonical rule: its `TagConfig` JSON carries `"isArray": true` and `"arrayLength": N` (N ≥ 1). The canonical
`isArray: true` + `arrayLength >= 1` → array; `isArray: false` (any length) → scalar. 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 ### One decode for poll and native notifications
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.
⚠️ **Native notifications do not carry arrays.** The notification path registers the An array is read — by a poll **and** by a native notification — as **raw bytes** at a layout
declared *scalar* type, so before the declared-type check an array tag subscribed natively planned from the PLC's **resolved** array type (`TwinCATTypeGuard.CheckArray`), and decoded by
published only its first element at Good. It is now refused with `BadTypeMismatch` (the PLC one codec (`TwinCATArrayCodec`). Both paths therefore publish the identical value: the
symbol is an array, not a scalar). Subscribe array tags with `UseNativeNotifications=false` element-typed CLR array (`bool[]`, `sbyte[]`, `byte[]`, `short[]`, `ushort[]`, `int[]`,
(the poll engine uses the array read above) until native array notifications are added. `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 - **A native notification registers the whole array** (element stride × N) as raw bytes, so
the array symbol read path via the fake ADS client factory. The TwinCAT integration the PLC pushes the array on change and every notification decodes to the full array. Before
fixture is down during normal dev. 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 ADS documents no notification size limit, and a live TwinCAT 2.11 target registered and
arrays, per-element historization. `IRediscoverable` fires and now surfaces a `/hosts` re-browse prompt, but still does not rebuild the address space (#518) delivered a **131,072-byte** notification (`P_ETHERCATDIAGNOSIS.SLAVEADDRESS`,
on PLC re-download (unchanged semantics from scalar nodes). `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 See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
and the UI authoring flow. and the UI authoring flow.
@@ -99,7 +99,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
// is inherently scalar — arrayCount does not apply. // is inherently scalar — arrayCount does not apply.
if (bitIndex is int bit && type == TwinCATDataType.Bool) 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) var parentResult = await _client.ReadValueAsync(parent, typeof(uint), cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
if (parentResult.ErrorCode != AdsErrorCode.NoError) if (parentResult.ErrorCode != AdsErrorCode.NoError)
@@ -107,31 +107,29 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
return (ExtractBit(parentResult.Value, bit), TwinCATStatusMapper.Good); return (ExtractBit(parentResult.Value, bit), TwinCATStatusMapper.Good);
} }
// Array read — ADS reads array symbols natively into a typed managed array. We ask // Resolve the PLC symbol first so the declared type can be checked against it. The symbol is
// 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<T[]> 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
// resolved through the Beckhoff symbol cache (the same lookup ReadValueAsync makes // resolved through the Beckhoff symbol cache (the same lookup ReadValueAsync makes
// internally), so this costs no extra round trip after the first read. // 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); .ConfigureAwait(false);
if (symbolStatus != TwinCATStatusMapper.Good) return (null, symbolStatus); 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) if (TwinCATTypeGuard.CheckRead(type, shape) is not null)
return (null, TwinCATStatusMapper.BadTypeMismatch); return (null, TwinCATStatusMapper.BadTypeMismatch);
@@ -170,21 +168,34 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
/// <see cref="TwinCATTypeGuard"/> compares a declared type against. Uses the Beckhoff symbol /// <see cref="TwinCATTypeGuard"/> compares a declared type against. Uses the Beckhoff symbol
/// cache (lookup on), so repeat calls are local. /// cache (lookup on), so repeat calls are local.
/// </summary> /// </summary>
private async Task<(TwinCATPlcSymbolShape shape, IAdsSymbol? symbol, uint status)> DescribeSymbolAsync( private async Task<(TwinCATPlcSymbolShape shape, TwinCATPlcArrayShape? array, IAdsSymbol? symbol, uint status)>
string symbolPath, CancellationToken cancellationToken) DescribeSymbolAsync(string symbolPath, CancellationToken cancellationToken)
{ {
var result = await _client.ReadSymbolAsync(symbolPath, cancellationToken).ConfigureAwait(false); var result = await _client.ReadSymbolAsync(symbolPath, cancellationToken).ConfigureAwait(false);
if (result.ErrorCode != AdsErrorCode.NoError) 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) 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 // 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. // resolved type's byte size — a TwinCAT 2 member symbol's own ByteSize is under-reported.
var dataType = symbol.DataType?.Resolve(); var dataType = symbol.DataType?.Resolve();
var shape = TwinCATPlcSymbolShape.Classify( var shape = TwinCATPlcSymbolShape.Classify(
dataType?.Name ?? symbol.TypeName, dataType?.Category, symbol.ByteSize, dataType?.ByteSize, symbol.TypeName); dataType?.Name ?? symbol.TypeName, dataType?.Category, symbol.ByteSize, dataType?.ByteSize, symbol.TypeName);
return (shape, symbol, TwinCATStatusMapper.Good); return (shape, DescribeArray(dataType), symbol, TwinCATStatusMapper.Good);
}
/// <summary>
/// The array facts <see cref="TwinCATTypeGuard.CheckArray"/> plans from: every dimension's element
/// count, the resolved array size, and the alias-resolved element type. Null for a non-array.
/// </summary>
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);
} }
/// <summary> /// <summary>
@@ -246,15 +257,6 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
? TwinCATStringCodec.DecodeWString(bytes) ? TwinCATStringCodec.DecodeWString(bytes)
: TwinCATStringCodec.DecodeString(bytes, utf8); : 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;
}
/// <inheritdoc /> /// <inheritdoc />
public async Task<uint> WriteValueAsync( public async Task<uint> WriteValueAsync(
string symbolPath, string symbolPath,
@@ -297,6 +299,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
string symbolPath, string symbolPath,
TwinCATDataType type, TwinCATDataType type,
int? bitIndex, int? bitIndex,
int? arrayCount,
TimeSpan cycleTime, TimeSpan cycleTime,
int maxDelayMs, int maxDelayMs,
Action<string, object?> onChange, Action<string, object?> onChange,
@@ -305,24 +308,59 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
var clrType = MapToClrType(type); var clrType = MapToClrType(type);
int[]? anyTypeArgs = null; int[]? anyTypeArgs = null;
var stringsUtf8 = false; 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 // 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 // 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. // 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); .ConfigureAwait(false);
if (symbolStatus != TwinCATStatusMapper.Good) if (symbolStatus != TwinCATStatusMapper.Good)
throw new InvalidOperationException( throw new InvalidOperationException(
$"Resolving {symbolPath} for a notification failed with status 0x{symbolStatus:X8}"); $"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); throw new TwinCATTypeMismatchException(symbolPath, reason);
}
// A string has no fixed width: the "any" type string needs an explicit length (the // 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 // 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 // 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. // 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[]); clrType = typeof(byte[]);
anyTypeArgs = [shape.ByteSize]; 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 // with the handle as part of the event args so we use the handle as the correlation
// key into _notifications. // key into _notifications.
var result = await _client.AddDeviceNotificationExAsync( var result = await _client.AddDeviceNotificationExAsync(
symbolPath, settings, userData: null, clrType, anyTypeArgs, cancellationToken) registerPath, settings, userData: null, clrType, anyTypeArgs, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
if (result.ErrorCode != AdsErrorCode.NoError) if (result.ErrorCode != AdsErrorCode.NoError)
throw new InvalidOperationException( 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) var reg = new NotificationRegistration(symbolPath, type, bitIndex, onChange, this, result.Handle)
{ {
StringsUtf8 = stringsUtf8, StringsUtf8 = stringsUtf8,
ArrayLayout = arrayLayout,
}; };
_notifications[result.Handle] = reg; _notifications[result.Handle] = reg;
return reg; return reg;
} }
/// <summary> /// <summary>
/// Runs on the <see cref="AdsClient"/> AMS router thread. Does the cheap bit-extraction /// Runs on the <see cref="AdsClient"/> AMS router thread. Does the cheap decode (bit
/// decode then enqueues — no driver logic, no consumer callbacks — so a slow consumer /// 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. /// can never stall ADS notification delivery for the rest of the process.
/// Drops the notification (DropWrite) if the queue is saturated. /// Drops the notification (DropWrite) if the queue is saturated.
/// </summary> /// </summary>
@@ -361,8 +400,16 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
{ {
if (!_notifications.TryGetValue(args.Handle, out var reg)) return; if (!_notifications.TryGetValue(args.Handle, out var reg)) return;
var value = args.Value; var value = args.Value;
if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool && value is not bool) if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool)
value = ExtractBit(value, bit); {
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) else if (value is byte[] raw && reg.Type is TwinCATDataType.String or TwinCATDataType.WString)
value = DecodeString(reg.Type, raw, reg.StringsUtf8); value = DecodeString(reg.Type, raw, reg.StringsUtf8);
_notificationQueue.Writer.TryWrite(new PendingNotification(reg, value)); _notificationQueue.Writer.TryWrite(new PendingNotification(reg, value));
@@ -615,6 +662,11 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
/// </summary> /// </summary>
public bool StringsUtf8 { get; init; } public bool StringsUtf8 { get; init; }
/// <summary>
/// Gets the raw-bytes layout of an array notification, decoded by <see cref="TwinCATArrayCodec"/>; null for a scalar.
/// </summary>
public TwinCATArrayLayout? ArrayLayout { get; init; }
/// <summary> /// <summary>
/// Disposes the notification subscription. /// Disposes the notification subscription.
/// </summary> /// </summary>
@@ -41,9 +41,11 @@ public interface ITwinCATClient : IDisposable
/// <param name="symbolPath">The ADS symbol path.</param> /// <param name="symbolPath">The ADS symbol path.</param>
/// <param name="type">The target data type.</param> /// <param name="type">The target data type.</param>
/// <param name="bitIndex">Optional bit index for bit extraction within a word.</param> /// <param name="bitIndex">Optional bit index for bit extraction within a word.</param>
/// <param name="arrayCount">When non-null, read a 1-D array of this many <paramref name="type"/> /// <param name="arrayCount">When non-null, read an array of this many <paramref name="type"/>
/// elements; the boxed value is the element-typed CLR array (<c>int[]</c> / <c>float[]</c> / /// elements (a multi-dimensional PLC array flattened in memory order; a count shorter than the PLC
/// <c>bool[]</c> / <c>string[]</c> / …). When null, read a scalar (Phase 4c).</param> /// array reads its leading sub-range); the boxed value is the element-typed CLR array
/// (<c>int[]</c> / <c>float[]</c> / <c>bool[]</c> / <c>string[]</c> / …) decoded by
/// <see cref="TwinCATArrayCodec"/>. When null, read a scalar.</param>
/// <param name="cancellationToken">Cancellation token for the read operation.</param> /// <param name="cancellationToken">Cancellation token for the read operation.</param>
/// <returns>The boxed value read (or <c>null</c>) together with the mapped OPC UA status.</returns> /// <returns>The boxed value read (or <c>null</c>) together with the mapped OPC UA status.</returns>
Task<(object? value, uint status)> ReadValueAsync( Task<(object? value, uint status)> ReadValueAsync(
@@ -87,6 +89,10 @@ public interface ITwinCATClient : IDisposable
/// <param name="symbolPath">ADS symbol path (e.g. <c>MAIN.bStart</c>).</param> /// <param name="symbolPath">ADS symbol path (e.g. <c>MAIN.bStart</c>).</param>
/// <param name="type">Declared type; drives the native layout + callback value boxing.</param> /// <param name="type">Declared type; drives the native layout + callback value boxing.</param>
/// <param name="bitIndex">For BOOL-within-word tags — the bit to extract from the parent word.</param> /// <param name="bitIndex">For BOOL-within-word tags — the bit to extract from the parent word.</param>
/// <param name="arrayCount">When non-null (and no <paramref name="bitIndex"/>), register the whole
/// array: <paramref name="arrayCount"/> elements of <paramref name="type"/> as raw bytes, decoded per
/// notification into the same element-typed CLR array <see cref="ReadValueAsync"/> returns
/// (<see cref="TwinCATArrayCodec"/>). Null = a scalar.</param>
/// <param name="cycleTime">Minimum interval between change notifications (native-floor depends on target).</param> /// <param name="cycleTime">Minimum interval between change notifications (native-floor depends on target).</param>
/// <param name="maxDelayMs">Maximum batching delay in milliseconds — TwinCAT may coalesce /// <param name="maxDelayMs">Maximum batching delay in milliseconds — TwinCAT may coalesce
/// notifications up to this delay before pushing them. <c>0</c> = no batching, push /// notifications up to this delay before pushing them. <c>0</c> = no batching, push
@@ -94,13 +100,18 @@ public interface ITwinCATClient : IDisposable
/// <param name="onChange">Invoked with <c>(symbolPath, boxedValue)</c> per notification.</param> /// <param name="onChange">Invoked with <c>(symbolPath, boxedValue)</c> per notification.</param>
/// <param name="cancellationToken">Cancels the initial registration; does not tear down an established notification.</param> /// <param name="cancellationToken">Cancels the initial registration; does not tear down an established notification.</param>
/// <returns>A handle whose disposal tears the registered notification down.</returns> /// <returns>A handle whose disposal tears the registered notification down.</returns>
/// <exception cref="TwinCATTypeMismatchException">The declared <paramref name="type"/> cannot decode /// <exception cref="TwinCATNotificationRefusedException">The notification is refused for this one tag
/// the PLC symbol (width, float/integer or string kind differ) — the caller isolates that one tag /// — the declared <paramref name="type"/> / <paramref name="arrayCount"/> cannot decode the PLC
/// to Bad rather than registering a notification that would publish wrong bytes at Good.</exception> /// symbol (<see cref="TwinCATTypeMismatchException"/>: width, float/integer or string kind differ,
/// not an array, too long), or the array exceeds
/// <see cref="TwinCATTypeGuard.MaxNotificationArrayBytes"/>. The caller isolates that one tag to the
/// exception's status rather than registering a notification that would publish wrong bytes at
/// Good.</exception>
Task<ITwinCATNotificationHandle> AddNotificationAsync( Task<ITwinCATNotificationHandle> AddNotificationAsync(
string symbolPath, string symbolPath,
TwinCATDataType type, TwinCATDataType type,
int? bitIndex, int? bitIndex,
int? arrayCount,
TimeSpan cycleTime, TimeSpan cycleTime,
int maxDelayMs, int maxDelayMs,
Action<string, object?> onChange, Action<string, object?> onChange,
@@ -0,0 +1,125 @@
using System.Buffers.Binary;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// Decodes a PLC array's raw bytes — laid out by <see cref="TwinCATTypeGuard.CheckArray"/> — into the
/// element-typed CLR array an array tag publishes (<c>bool[]</c>, <c>short[]</c>, <c>float[]</c>,
/// <c>string[]</c>, …). Both the poll read and the native notification decode here, so the two paths
/// publish identical values, element type and length by construction.
/// </summary>
/// <remarks>
/// 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 (<c>ARRAY OF TIME</c> as <c>uint[]</c> threw), a
/// multi-dimensional array demanded a rank-N CLR array, and a dotted MEMBER array was sliced by the
/// under-reported symbol size (<c>ArgumentOutOfRangeException</c>). Elements are little-endian (every
/// TwinCAT target is x86/x64/ARM-LE); a STRING / WSTRING element is decoded by
/// <see cref="TwinCATStringCodec"/> within its fixed stride, so each ends at its own first NUL.
/// </remarks>
internal static class TwinCATArrayCodec
{
/// <summary>Decodes <paramref name="layout"/>.ElementCount elements of <paramref name="declared"/>.</summary>
/// <param name="declared">The tag's declared element type.</param>
/// <param name="bytes">The PLC bytes from the array's first element (at least <see cref="TwinCATArrayLayout.ByteLength"/>).</param>
/// <param name="layout">The planned layout.</param>
/// <param name="utf8">True when the target reports UTF-8 STRING data (else Windows-1252).</param>
/// <returns>The element-typed CLR array.</returns>
/// <exception cref="ArgumentException">The buffer is shorter than the layout.</exception>
public static Array Decode(TwinCATDataType declared, ReadOnlySpan<byte> 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");
}
}
}
@@ -0,0 +1,41 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// Serves a bit-indexed BOOL tag (<c>MAIN.Flags.3</c>, <c>.ADSSTATE_RUN.0</c>) from its PARENT word.
/// ADS has no symbol for <c>WORD.N</c> — live on a TwinCAT 2.11 target a notification registered on
/// <c>.ADSSTATE_RUN.0</c> failed with <c>DeviceSymbolNotFound</c> — 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.
/// </summary>
internal static class TwinCATBitAccess
{
/// <summary>The parent word's path: the bit path without its trailing <c>.N</c>.</summary>
/// <param name="symbolPath">The bit path (<c>MAIN.Flags.3</c>).</param>
/// <returns>The parent path, or <paramref name="symbolPath"/> when it has no numeric last segment.</returns>
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;
}
/// <summary>Checks that <paramref name="bit"/> addresses a bit of an integer parent word.</summary>
/// <param name="bit">The bit index.</param>
/// <param name="parent">The parent symbol's shape.</param>
/// <returns><c>null</c> when addressable; otherwise the reason.</returns>
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}";
}
/// <summary>Reads bit <paramref name="bit"/> of a little-endian parent word.</summary>
/// <param name="parent">The parent word's raw bytes.</param>
/// <param name="bit">The bit index (0 = least significant bit of the first byte).</param>
/// <returns>The bit's value.</returns>
public static bool ReadBit(ReadOnlySpan<byte> parent, int bit) =>
(parent[bit / 8] & (1 << (bit % 8))) != 0;
}
@@ -263,10 +263,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath); var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath; var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
// An array-typed tag (def.ArrayLength != null) drives a native 1-D ADS array read; // An array-typed tag (def.ArrayLength != null) reads the array as raw bytes and decodes
// the boxed result is an element-typed CLR array. Scalar tags pass null // it with TwinCATArrayCodec — the same decode a native array notification runs — into an
// (scalar path) — bit-indexed BOOLs are inherently scalar so the client ignores // element-typed CLR array. Scalar tags pass null (scalar path) — bit-indexed BOOLs are
// arrayCount when a bitIndex is present (Phase 4c). // inherently scalar so the client ignores arrayCount when a bitIndex is present.
var (value, status) = await client.ReadValueAsync( var (value, status) = await client.ReadValueAsync(
symbolName, def.DataType, parsed?.BitIndex, def.ArrayLength, cancellationToken) symbolName, def.DataType, parsed?.BitIndex, def.ArrayLength, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
@@ -279,7 +279,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
if (status == TwinCATStatusMapper.BadTypeMismatch) if (status == TwinCATStatusMapper.BadTypeMismatch)
_logger.LogWarning( _logger.LogWarning(
"TwinCAT driver '{DriverInstanceId}' refused '{Reference}': declared {DataType} " + "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); _driverInstanceId, reference, def.DataType, symbolName);
else else
_logger.LogWarning( _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. // client swap unchanged — replay re-registers the SAME intent onto the new client.
var reg = new NativeRegistration( var reg = new NativeRegistration(
Interlocked.Increment(ref _nextRegId), device, reference, symbolName, Interlocked.Increment(ref _nextRegId), device, reference, symbolName,
def.DataType, bitIndex, publishingInterval, def.DataType, bitIndex, def.ArrayLength, publishingInterval,
onChange: (_, value) => OnDataChange?.Invoke(this, onChange: (_, value) => OnDataChange?.Invoke(this,
new DataChangeEventArgs(handle, reference, new DataValueSnapshot( new DataChangeEventArgs(handle, reference, new DataValueSnapshot(
value, TwinCATStatusMapper.Good, DateTime.UtcNow, DateTime.UtcNow))), 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); 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): // The declared type / array length cannot decode this PLC symbol (ignitionoee
// isolate THIS tag to Bad — one WARN, one BadTypeMismatch snapshot — and keep the // TwinCAT-002/-008), or the array is over the native-notification size cap: isolate
// rest of the subscription. Its intent stays on the device with no live handle, // THIS tag to Bad — one WARN, one snapshot carrying the refusal's status
// so the probe-tick retry re-checks it after a PLC program change. // (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( _logger.LogWarning(
"TwinCAT driver '{DriverInstanceId}' refused native notification for '{Reference}': {Reason}", "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( OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, reference, new DataValueSnapshot(
null, TwinCATStatusMapper.BadTypeMismatch, null, DateTime.UtcNow))); null, refused.StatusCode, null, DateTime.UtcNow)));
} }
registrations.Add(reg); registrations.Add(reg);
} }
@@ -594,10 +597,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
try try
{ {
newHandle = await client.AddNotificationAsync( 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); _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 // 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. // 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; } public TwinCATDataType DataType { get; }
/// <summary>Bit index for a BOOL-within-word symbol; null otherwise.</summary> /// <summary>Bit index for a BOOL-within-word symbol; null otherwise.</summary>
public int? BitIndex { get; } public int? BitIndex { get; }
/// <summary>Authored array length — the whole array is registered and published; null for a scalar.</summary>
public int? ArrayLength { get; }
/// <summary>Requested notification interval.</summary> /// <summary>Requested notification interval.</summary>
public TimeSpan Interval { get; } public TimeSpan Interval { get; }
/// <summary>Value-change callback (closes over the owning subscription handle + reference).</summary> /// <summary>Value-change callback (closes over the owning subscription handle + reference).</summary>
@@ -687,11 +692,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// <param name="symbolName">ADS symbol name (resolved from the symbol path).</param> /// <param name="symbolName">ADS symbol name (resolved from the symbol path).</param>
/// <param name="dataType">Declared data type.</param> /// <param name="dataType">Declared data type.</param>
/// <param name="bitIndex">Bit index for a BOOL-within-word symbol; null otherwise.</param> /// <param name="bitIndex">Bit index for a BOOL-within-word symbol; null otherwise.</param>
/// <param name="arrayLength">Authored array length for an array tag; null for a scalar.</param>
/// <param name="interval">Requested notification interval.</param> /// <param name="interval">Requested notification interval.</param>
/// <param name="onChange">Value-change callback (closes over the owning subscription handle + reference).</param> /// <param name="onChange">Value-change callback (closes over the owning subscription handle + reference).</param>
/// <param name="onUnavailable">Bad-quality callback fired when the handle goes dead (05/STAB-16).</param> /// <param name="onUnavailable">Bad-quality callback fired when the handle goes dead (05/STAB-16).</param>
public NativeRegistration(long id, DeviceState device, string reference, string symbolName, public NativeRegistration(long id, DeviceState device, string reference, string symbolName,
TwinCATDataType dataType, int? bitIndex, TimeSpan interval, Action<string, object?> onChange, TwinCATDataType dataType, int? bitIndex, int? arrayLength, TimeSpan interval, Action<string, object?> onChange,
Action onUnavailable) Action onUnavailable)
{ {
Id = id; Id = id;
@@ -700,6 +706,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
SymbolName = symbolName; SymbolName = symbolName;
DataType = dataType; DataType = dataType;
BitIndex = bitIndex; BitIndex = bitIndex;
ArrayLength = arrayLength;
Interval = interval; Interval = interval;
OnChange = onChange; OnChange = onChange;
OnUnavailable = onUnavailable; OnUnavailable = onUnavailable;
@@ -969,7 +976,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
try try
{ {
var newHandle = await client.AddNotificationAsync( 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); _options.NotificationMaxDelayMs, reg.OnChange, ct).ConfigureAwait(false);
var old = reg.SwapHandle(newHandle); var old = reg.SwapHandle(newHandle);
try { old?.Dispose(); } catch { /* dead handle from the disposed client */ } try { old?.Dispose(); } catch { /* dead handle from the disposed client */ }
@@ -1034,7 +1041,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
try try
{ {
var newHandle = await client.AddNotificationAsync( 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); _options.NotificationMaxDelayMs, reg.OnChange, ct).ConfigureAwait(false);
reg.SwapHandle(newHandle); reg.SwapHandle(newHandle);
// STAB-17 ownership re-check — see ReplayNativeRegistrationsAsync. // STAB-17 ownership re-check — see ReplayNativeRegistrationsAsync.
@@ -0,0 +1,28 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// Thrown by <see cref="ITwinCATClient.AddNotificationAsync"/> when the client refuses to register a
/// native notification for one tag — its declared type cannot decode the PLC symbol
/// (<see cref="TwinCATTypeMismatchException"/>), or an array is too large for a native notification.
/// The driver isolates that one tag to <see cref="StatusCode"/> (one WARN) and keeps the rest of the
/// subscription; the intent stays registered so the probe-tick retry re-checks it.
/// </summary>
internal class TwinCATNotificationRefusedException : Exception
{
/// <summary>Initializes a new instance of the <see cref="TwinCATNotificationRefusedException"/> class.</summary>
/// <param name="symbolPath">The ADS symbol the tag names.</param>
/// <param name="reason">The one-line reason.</param>
/// <param name="statusCode">The OPC UA status the refused tag publishes.</param>
public TwinCATNotificationRefusedException(string symbolPath, string reason, uint statusCode)
: base($"TwinCAT symbol '{symbolPath}': {reason}")
{
SymbolPath = symbolPath;
StatusCode = statusCode;
}
/// <summary>Gets the ADS symbol the refused tag names.</summary>
public string SymbolPath { get; }
/// <summary>Gets the OPC UA status the refused tag publishes.</summary>
public uint StatusCode { get; }
}
@@ -24,7 +24,9 @@ internal enum TwinCATPlcKind
/// <param name="ByteSize">The symbol's byte size as the PLC reports it.</param> /// <param name="ByteSize">The symbol's byte size as the PLC reports it.</param>
/// <param name="IsFloat">True for a REAL / LREAL scalar.</param> /// <param name="IsFloat">True for a REAL / LREAL scalar.</param>
/// <param name="TypeName">The (alias-resolved) PLC type name, for log messages.</param> /// <param name="TypeName">The (alias-resolved) PLC type name, for log messages.</param>
internal readonly record struct TwinCATPlcSymbolShape(TwinCATPlcKind Kind, int ByteSize, bool IsFloat, string TypeName) /// <param name="IsArray">True for an ARRAY (a <see cref="TwinCATPlcKind.NonScalar"/> with array category or name).</param>
internal readonly record struct TwinCATPlcSymbolShape(
TwinCATPlcKind Kind, int ByteSize, bool IsFloat, string TypeName, bool IsArray = false)
{ {
private static readonly Regex StringTypeName = private static readonly Regex StringTypeName =
new(@"^(W?)STRING(\s*\(\s*\d+\s*\))?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); 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("POINTER TO", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("REFERENCE TO", StringComparison.OrdinalIgnoreCase) || name.StartsWith("REFERENCE TO", StringComparison.OrdinalIgnoreCase)
|| name.Contains("VAR_IN_OUT", 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); var mapped = AdsTwinCATClient.MapSymbolTypeName(name);
return new(TwinCATPlcKind.Scalar, byteSize, mapped is TwinCATDataType.Real or TwinCATDataType.LReal, name); return new(TwinCATPlcKind.Scalar, byteSize, mapped is TwinCATDataType.Real or TwinCATDataType.LReal, name);
} }
} }
/// <summary>The facts about a PLC ARRAY symbol <see cref="TwinCATTypeGuard.CheckArray"/> plans a layout from.</summary>
/// <param name="DimensionLengths">The element count of each dimension (lower bounds play no part: the
/// driver publishes a 0-based, flattened array in PLC memory order).</param>
/// <param name="ByteSize">The array's byte size, from its RESOLVED data type (a TwinCAT 2 member symbol
/// under-reports its own size).</param>
/// <param name="Element">The (alias-resolved) element type, classified like a scalar symbol.</param>
internal readonly record struct TwinCATPlcArrayShape(IReadOnlyList<int> DimensionLengths, int ByteSize, TwinCATPlcSymbolShape Element)
{
/// <summary>Gets the total element count across every dimension (0 when any dimension is empty).</summary>
public long ElementCount => DimensionLengths.Aggregate(1L, (total, length) => total * Math.Max(0, length));
}
/// <summary>
/// How an array tag is read from the PLC's raw bytes: <paramref name="ElementCount"/> elements at a
/// fixed <paramref name="Stride"/>, starting at the array's first byte.
/// </summary>
/// <param name="ElementCount">The number of elements published (the authored <c>ArrayLength</c>).</param>
/// <param name="Stride">The bytes per element: the element type's width, or n+1 / 2(n+1) for a
/// <c>STRING(n)</c> / <c>WSTRING(n)</c>.</param>
internal readonly record struct TwinCATArrayLayout(int ElementCount, int Stride)
{
/// <summary>Gets the number of bytes the read or notification covers.</summary>
public int ByteLength => ElementCount * Stride;
}
/// <summary> /// <summary>
/// Decides whether a tag's DECLARED <see cref="TwinCATDataType"/> may decode the PLC symbol it names — /// Decides whether a tag's DECLARED <see cref="TwinCATDataType"/> may decode the PLC symbol it names —
/// the port of the ignitionoee TwinCAT driver's extra-type reconciliation (TwinCAT-002, -008). A /// 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
/// <list type="bullet"> /// <list type="bullet">
/// <item><b>Read</b> (<see cref="CheckRead"/>) goes through the Beckhoff symbol cache, which /// <item><b>Read</b> (<see cref="CheckRead"/>) 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 /// 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 /// pair is value-converted, never reinterpreted. Only string-ness can go wrong there (a
/// numeric tag over a PLC string got a <c>string</c> object back at Good.</item> /// numeric tag over a PLC string got a <c>string</c> object back at Good), plus a scalar over
/// an ARRAY, which the read cannot convert at all.</item>
/// <item><b>Notification</b> (<see cref="CheckNotification"/>) registers raw bytes decoded as the /// <item><b>Notification</b> (<see cref="CheckNotification"/>) registers raw bytes decoded as the
/// DECLARED type, so a width mismatch truncates/over-reads and a same-width float/integer clash /// DECLARED type, so a width mismatch truncates/over-reads and a same-width float/integer clash
/// reinterprets the bit pattern — both at Good.</item> /// reinterprets the bit pattern — both at Good.</item>
/// <item><b>Array tags</b> (<see cref="CheckArray"/>) are read AND notified as raw bytes, so the
/// element gets the notification rules on both paths.</item>
/// </list> /// </list>
/// String-ness is a KIND (TwinCAT-008): a STRING/WSTRING tag matches only a PLC string of the SAME /// 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 /// 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 " + : $"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"; $"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 return plc.Kind is TwinCATPlcKind.String or TwinCATPlcKind.WString
? $"declared {declared} but the PLC symbol is the string {plc.TypeName} — declare it String/WString" ? $"declared {declared} but the PLC symbol is the string {plc.TypeName} — declare it String/WString"
: null; : null;
@@ -144,6 +178,72 @@ internal static class TwinCATTypeGuard
: $"declared {declared} but the PLC symbol is {plc.TypeName} — float/integer kinds differ"; : $"declared {declared} but the PLC symbol is {plc.TypeName} — float/integer kinds differ";
} }
/// <summary>
/// The largest array a native notification registers, in bytes: 128 KiB — the largest registration
/// proven on a real target (a TwinCAT 2.11 <c>ARRAY [0..65535] OF UINT</c>, 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.
/// </summary>
public const int MaxNotificationArrayBytes = 128 * 1024;
/// <summary>
/// Checks an ARRAY tag — its declared element type and authored <c>ArrayLength</c> — against a PLC
/// array, and plans the raw-bytes layout both the poll read and the native notification decode
/// with <see cref="TwinCATArrayCodec"/>.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item>The element is checked like a scalar notification (<see cref="CheckNotification"/>):
/// 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.</item>
/// <item>A multi-dimensional array is published flattened in PLC memory order (the last index
/// varies fastest); its <c>ArrayLength</c> is the total element count. Lower bounds play no
/// part — the published index is 0-based.</item>
/// <item>An authored <c>ArrayLength</c> 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.</item>
/// </list>
/// </remarks>
/// <param name="declared">The tag's declared element type.</param>
/// <param name="declaredLength">The tag's authored <c>ArrayLength</c>.</param>
/// <param name="symbol">The PLC symbol's shape (decides a non-array symbol; names it in messages).</param>
/// <param name="array">The PLC array's shape, or null when the symbol is not an array.</param>
/// <param name="layout">The planned layout when compatible; otherwise default.</param>
/// <returns><c>null</c> when compatible; otherwise the reason.</returns>
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;
}
/// <summary>Checks a planned array layout against <see cref="MaxNotificationArrayBytes"/>.</summary>
/// <param name="layout">The planned layout.</param>
/// <returns><c>null</c> when it fits; otherwise the reason.</returns>
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";
/// <summary>The byte width the declared type decodes from a notification buffer.</summary> /// <summary>The byte width the declared type decodes from a notification buffer.</summary>
/// <param name="type">The declared type.</param> /// <param name="type">The declared type.</param>
/// <returns>The width in bytes.</returns> /// <returns>The width in bytes.</returns>
@@ -2,21 +2,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary> /// <summary>
/// Thrown by <see cref="ITwinCATClient.AddNotificationAsync"/> when a tag's declared /// Thrown by <see cref="ITwinCATClient.AddNotificationAsync"/> when a tag's declared
/// <see cref="TwinCATDataType"/> cannot decode the PLC symbol it names (see /// <see cref="TwinCATDataType"/> (or, for an array tag, its element type / <c>ArrayLength</c>) cannot
/// <see cref="TwinCATTypeGuard"/>). The driver isolates the one tag to /// decode the PLC symbol it names (see <see cref="TwinCATTypeGuard"/>). The driver isolates the one
/// <see cref="TwinCATStatusMapper.BadTypeMismatch"/> and keeps the rest of the subscription. /// tag to <see cref="TwinCATStatusMapper.BadTypeMismatch"/> and keeps the rest of the subscription.
/// </summary> /// </summary>
internal sealed class TwinCATTypeMismatchException : Exception internal sealed class TwinCATTypeMismatchException : TwinCATNotificationRefusedException
{ {
/// <summary>Initializes a new instance of the <see cref="TwinCATTypeMismatchException"/> class.</summary> /// <summary>Initializes a new instance of the <see cref="TwinCATTypeMismatchException"/> class.</summary>
/// <param name="symbolPath">The ADS symbol the tag names.</param> /// <param name="symbolPath">The ADS symbol the tag names.</param>
/// <param name="reason">The guard's one-line reason.</param> /// <param name="reason">The guard's one-line reason.</param>
public TwinCATTypeMismatchException(string symbolPath, string reason) public TwinCATTypeMismatchException(string symbolPath, string reason)
: base($"TwinCAT symbol '{symbolPath}': {reason}") : base(symbolPath, reason, TwinCATStatusMapper.BadTypeMismatch)
{ {
SymbolPath = symbolPath;
} }
/// <summary>Gets the ADS symbol the refused tag names.</summary>
public string SymbolPath { get; }
} }
@@ -129,13 +129,14 @@ internal class FakeTwinCATClient : ITwinCATClient
/// <param name="symbolPath">The path to the symbol to watch.</param> /// <param name="symbolPath">The path to the symbol to watch.</param>
/// <param name="type">The data type of the symbol.</param> /// <param name="type">The data type of the symbol.</param>
/// <param name="bitIndex">The optional bit index for bit-level notifications.</param> /// <param name="bitIndex">The optional bit index for bit-level notifications.</param>
/// <param name="arrayCount">The authored array length for an array tag; null for a scalar.</param>
/// <param name="cycleTime">The sampling cycle time.</param> /// <param name="cycleTime">The sampling cycle time.</param>
/// <param name="maxDelayMs">The maximum delay in milliseconds.</param> /// <param name="maxDelayMs">The maximum delay in milliseconds.</param>
/// <param name="onChange">The callback to invoke on value change.</param> /// <param name="onChange">The callback to invoke on value change.</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that returns a notification handle.</returns> /// <returns>A task that returns a notification handle.</returns>
public virtual async Task<ITwinCATNotificationHandle> AddNotificationAsync( public virtual async Task<ITwinCATNotificationHandle> AddNotificationAsync(
string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime, string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, TimeSpan cycleTime,
int maxDelayMs, Action<string, object?> onChange, CancellationToken cancellationToken) int maxDelayMs, Action<string, object?> onChange, CancellationToken cancellationToken)
{ {
if (ThrowOnAddNotification) if (ThrowOnAddNotification)
@@ -147,7 +148,7 @@ internal class FakeTwinCATClient : ITwinCATClient
await gate.Task.ConfigureAwait(false); await gate.Task.ConfigureAwait(false);
LastMaxDelayMs = maxDelayMs; 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); Notifications.Add(reg);
return reg; return reg;
} }
@@ -195,6 +196,8 @@ internal class FakeTwinCATClient : ITwinCATClient
public TwinCATDataType Type { get; } = type; public TwinCATDataType Type { get; } = type;
/// <summary>Gets the optional bit index.</summary> /// <summary>Gets the optional bit index.</summary>
public int? BitIndex { get; } = bitIndex; public int? BitIndex { get; } = bitIndex;
/// <summary>Gets the array count the driver registered with (null = scalar).</summary>
public int? ArrayCount { get; init; }
/// <summary>Gets the callback to invoke on value change.</summary> /// <summary>Gets the callback to invoke on value change.</summary>
public Action<string, object?> OnChange { get; } = onChange; public Action<string, object?> OnChange { get; } = onChange;
/// <summary>Gets a value indicating whether this notification has been disposed.</summary> /// <summary>Gets a value indicating whether this notification has been disposed.</summary>
@@ -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;
/// <summary>
/// Pins the array half of the raw-bytes model: <see cref="TwinCATTypeGuard.CheckArray"/> decides
/// whether an authored array tag (element type + <c>ArrayLength</c>) may decode a PLC array and
/// plans the byte layout, and <see cref="TwinCATArrayCodec.Decode"/> turns the PLC's raw bytes into
/// the element-typed CLR array both the poll read and the native notification publish.
/// </summary>
/// <remarks>
/// 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 (<c>ARRAY OF TIME</c> as <c>uint[]</c> threw), a multi-dimensional
/// array demanded a rank-2 CLR array, and a dotted MEMBER array was sliced by the under-reported
/// symbol size (<c>P_INPUTVALUESFILTER.FBUSPWR1G1.SAMPLE</c>, <c>ARRAY [1..20] OF REAL</c>, symbol
/// 10 B vs type 80 B → <c>ArgumentOutOfRangeException</c>).
/// </remarks>
[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 ----
/// <summary>A matching element type and length plans the whole array at element-size stride.</summary>
[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);
}
/// <summary>
/// A multi-dimensional array (<c>ARRAY [1..3, 1..2] OF REAL</c>, 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.
/// </summary>
[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));
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>An authored ArrayLength LONGER than the PLC array would read past it — refused.</summary>
[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");
/// <summary>A zero or negative authored length is not an array.</summary>
[Fact]
public void CheckArray_refuses_a_non_positive_authored_length()
=> Check(TwinCATDataType.UInt, 0, Arr(Elem("UINT", 2), 13), out _).ShouldNotBeNull();
/// <summary>
/// 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.
/// </summary>
[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();
}
/// <summary>A STRING(n) / WSTRING(n) element is laid out at the PLC's own width: n+1 / 2(n+1).</summary>
[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));
}
/// <summary>
/// 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.
/// </summary>
[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();
}
/// <summary>
/// An array tag over a PLC symbol that is not an array (a scalar, a string, an element path such
/// as <c>Arr[3]</c>) is refused: there is no array to read.
/// </summary>
[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();
}
/// <summary>A <c>VAR_IN_OUT</c> array member is a reference, not the array — refused.</summary>
[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();
}
/// <summary>
/// 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.
/// </summary>
[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 ----
/// <summary>
/// A native notification is capped at <see cref="TwinCATTypeGuard.MaxNotificationArrayBytes"/>
/// (128 KiB — the largest registration proven on a real target: a TC 2.11
/// <c>ARRAY [0..65535] OF UINT</c>). Above it the tag is refused with a reason pointing at polling.
/// </summary>
[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 ----
/// <summary>Each numeric element type decodes little-endian into its CLR array, in PLC memory order.</summary>
[Fact]
public void Decode_numeric_elements_little_endian_into_the_declared_clr_array()
{
Decode<short>(TwinCATDataType.Int, 2, [-1, 300], (b, v) => BinaryPrimitives.WriteInt16LittleEndian(b, v));
Decode<ushort>(TwinCATDataType.UInt, 2, [65535, 7], (b, v) => BinaryPrimitives.WriteUInt16LittleEndian(b, v));
Decode<int>(TwinCATDataType.DInt, 4, [-123456, 42], (b, v) => BinaryPrimitives.WriteInt32LittleEndian(b, v));
Decode<uint>(TwinCATDataType.UDInt, 4, [4_000_000_000, 1], (b, v) => BinaryPrimitives.WriteUInt32LittleEndian(b, v));
Decode<long>(TwinCATDataType.LInt, 8, [long.MinValue, 9], (b, v) => BinaryPrimitives.WriteInt64LittleEndian(b, v));
Decode<ulong>(TwinCATDataType.ULInt, 8, [ulong.MaxValue, 3], (b, v) => BinaryPrimitives.WriteUInt64LittleEndian(b, v));
Decode<float>(TwinCATDataType.Real, 4, [949.2f, -0.5f], (b, v) => BinaryPrimitives.WriteSingleLittleEndian(b, v));
Decode<double>(TwinCATDataType.LReal, 8, [Math.PI, -1e300], (b, v) => BinaryPrimitives.WriteDoubleLittleEndian(b, v));
Decode<sbyte>(TwinCATDataType.SInt, 1, [-128, 127], (b, v) => b[0] = unchecked((byte)v));
Decode<byte>(TwinCATDataType.USInt, 1, [0, 255], (b, v) => b[0] = v);
}
/// <summary>
/// TIME / DATE / DT / TOD elements publish as their raw 32-bit count (<c>uint[]</c>) — the same
/// CLR shape a scalar of those types publishes. Live: <c>.THROTTLE_MODE_DEFAULT</c>
/// (<c>ARRAY [0..12] OF TIME</c>) begins <c>0, 10, 20</c> ms.
/// </summary>
[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 });
/// <summary>A BOOL element is one byte; any non-zero byte is TRUE (live: <c>.ARGTYPE_IS_BIT_TYPE</c>).</summary>
[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 });
/// <summary>
/// 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.
/// </summary>
[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", "" });
}
/// <summary>Each WSTRING(n) element is UTF-16LE at a 2(n+1) stride, ending at its first aligned U+0000.</summary>
[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" });
}
/// <summary>A sub-range layout decodes only its leading elements, even from a longer buffer.</summary>
[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 });
/// <summary>A buffer shorter than the layout is a protocol fault, never a silently-short array.</summary>
[Fact]
public void Decode_refuses_a_buffer_shorter_than_the_layout()
=> Should.Throw<ArgumentException>(() =>
TwinCATArrayCodec.Decode(TwinCATDataType.Int, [1, 0, 2], new TwinCATArrayLayout(2, 2), utf8: false));
private static void Decode<T>(TwinCATDataType type, int stride, T[] values, Action<Span<byte>, 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<T[]>().ShouldBe(values);
}
}
@@ -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;
/// <summary>
/// Pins <see cref="TwinCATBitAccess"/> — how a bit-indexed BOOL tag (<c>.ADSSTATE_RUN.0</c>) is served by a
/// native notification. ADS has no symbol for <c>WORD.N</c>: live on the TwinCAT 2.11 Elma cleaner Z33856
/// (2026-10-07) registering the notification on <c>.ADSSTATE_RUN.0</c> failed with
/// <c>DeviceSymbolNotFound</c> — 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.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATBitAccessTests
{
/// <summary>The parent path is the bit path without its trailing <c>.N</c>; a non-bit path is unchanged.</summary>
[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");
}
/// <summary>Bits are numbered little-endian across the parent's bytes (bit 9 = byte 1, bit 1).</summary>
[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();
}
/// <summary>A bit past the parent's width cannot be read — refused instead of publishing FALSE at Good.</summary>
[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();
}
/// <summary>Only an integer word has addressable bits — a string, a float or a container is refused.</summary>
[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();
}
}
@@ -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;
/// <summary>
/// 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 <c>BadTypeMismatch</c>. The driver now hands the authored
/// <c>ArrayLength</c> to the client, which registers the whole array as raw bytes and decodes it
/// (<see cref="TwinCATArrayCodec"/>) — these pin the driver's half of that contract.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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<DataChangeEventArgs>();
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);
}
/// <summary>
/// A scalar tag, an element-indexed path (<c>Arr[3]</c>) and a bit-indexed BOOL stay scalars: no
/// array count reaches the client.
/// </summary>
[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);
}
/// <summary>A reconnect replays an array registration with the same array count.</summary>
[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);
}
/// <summary>
/// A notification the client refuses for a reason other than a type mismatch (an array above the
/// native-notification size cap → <c>BadOutOfRange</c>) is isolated the same way: that one tag
/// publishes the refusal's own status and the rest of the subscription registers.
/// </summary>
[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<DataChangeEventArgs>();
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();
}
/// <summary>A type-mismatch refusal is a notification refusal carrying <c>BadTypeMismatch</c>.</summary>
[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");
}
}
@@ -191,13 +191,13 @@ public sealed class TwinCATNativeNotificationTests
/// <inheritdoc /> /// <inheritdoc />
public override Task<ITwinCATNotificationHandle> AddNotificationAsync( public override Task<ITwinCATNotificationHandle> AddNotificationAsync(
string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime, string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, TimeSpan cycleTime,
int maxDelayMs, Action<string, object?> onChange, CancellationToken cancellationToken) int maxDelayMs, Action<string, object?> onChange, CancellationToken cancellationToken)
{ {
AddCallCount++; AddCallCount++;
if (AddCallCount > _succeedBefore) if (AddCallCount > _succeedBefore)
throw new InvalidOperationException($"fake fail on call #{AddCallCount}"); 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);
} }
} }
@@ -105,6 +105,26 @@ public sealed class TwinCATTypeGuardTests
TwinCATTypeGuard.CheckRead(TwinCATDataType.String, Plc("ST_LOGDATA", 81, DataTypeCategory.Struct)).ShouldNotBeNull(); TwinCATTypeGuard.CheckRead(TwinCATDataType.String, Plc("ST_LOGDATA", 81, DataTypeCategory.Struct)).ShouldNotBeNull();
} }
/// <summary>
/// A SCALAR tag over a PLC array has no value the symbol-aware read can convert: live on Z33856
/// (2026-10-07) <c>.ARRFAULTCODE</c> (<c>ARRAY [0..12] OF UINT</c>) read as <c>UInt</c> made the Beckhoff
/// read throw, surfacing as <c>BadCommunicationError</c> and a Degraded driver, while the native path
/// said <c>BadTypeMismatch</c>. Both paths now refuse it with <c>BadTypeMismatch</c> — 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.
/// </summary>
[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();
}
/// <summary> /// <summary>
/// 0.19.2: a STRING / WSTRING tag matches a PLC <c>STRING(n)</c> / <c>WSTRING(n)</c> of ANY length. /// 0.19.2: a STRING / WSTRING tag matches a PLC <c>STRING(n)</c> / <c>WSTRING(n)</c> of ANY length.
/// A numeric-over-numeric pair is left to the symbol-aware read, which converts the value. /// A numeric-over-numeric pair is left to the symbol-aware read, which converts the value.