merge: master (TwinCAT native array subscriptions) into feat/native-ab-transport
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+1
-1
@@ -595,7 +595,7 @@ unknown keys are preserved byte-stable on round-trip by all typed editors.
|
||||
| **S7** | `ReadBytesAsync` block + per-element decode loop | Unit-proven (fixture down) |
|
||||
| **AB CIP** | libplctag native array read (atomic + UDT member arrays) | Unit-proven (fixture down) |
|
||||
| **AB Legacy** | PCCC multi-element file read via libplctag (cap 256 elements) | Unit-proven (fixture down) |
|
||||
| **TwinCAT** | ADS native array symbol read | Unit-proven (fixture down) |
|
||||
| **TwinCAT** | Raw-bytes array read + per-element decode (native notifications carry the whole array) | Live-verified on TwinCAT 2.11 (2026-10-07) |
|
||||
|
||||
Array writes, multi-dimensional arrays (ValueRank>1), and per-element historization are out of scope — see [Uns.md §Array tags](Uns.md#array-tags-1-d).
|
||||
|
||||
|
||||
+1
-1
@@ -310,7 +310,7 @@ Combined with historization (values are arrays — history of the whole array sn
|
||||
| **S7** | Contiguous `ReadBytesAsync` block over the declared address span + per-element decode loop | Unit-proven (sim fixture down) |
|
||||
| **AB CIP** | libplctag native array read (atomic and UDT member arrays) | Unit-proven (sim fixture down) |
|
||||
| **AB Legacy** | PCCC multi-element file read via libplctag (cap 256 elements) | Unit-proven (sim fixture down) |
|
||||
| **TwinCAT** | ADS native array symbol read against the declared `SymbolPath` | Unit-proven (sim fixture down) |
|
||||
| **TwinCAT** | Raw-bytes read of the declared `SymbolPath` at the resolved array type's layout + per-element decode; native ADS notifications carry the whole array (same decode). Multi-dim PLC arrays publish flattened; a shorter `arrayLength` reads the leading sub-range | Live-verified 2026-10-07 on two TwinCAT 2.11 PLCs (see [TwinCAT.md §Array tags](drivers/TwinCAT.md#array-tags)) |
|
||||
|
||||
### Out of scope (named deferrals)
|
||||
|
||||
|
||||
@@ -93,6 +93,11 @@ All tests gate on `TWINCAT_TARGET_NETID` (required) via `[TwinCATFact]` /
|
||||
`BrowseSymbolsAsync` + system-symbol filtering
|
||||
- `TwinCATNativeNotificationTests` — `AddDeviceNotification` registration,
|
||||
callback-delivery-to-`OnDataChange` wiring, unregister on unsubscribe
|
||||
- `TwinCATNativeArraySubscribeTests` — array tags register with their authored
|
||||
length, replay after reconnect, and a refused tag is isolated with its status
|
||||
- `TwinCATArrayCodecTests` — array layout planning / validation
|
||||
(`TwinCATTypeGuard.CheckArray`, size cap) + raw-bytes decode of every element type
|
||||
- `TwinCATBitAccessTests` — bit-indexed BOOL notifications served from the parent word
|
||||
- `TwinCATDriverTests` — `IDriver` lifecycle
|
||||
|
||||
Capability surfaces whose contract is verified at the unit layer: `IDriver`,
|
||||
|
||||
+118
-20
@@ -202,6 +202,15 @@ a batching delay for high-churn signals. Set `UseNativeNotifications=false` for
|
||||
deployments where the AMS router has notification limits you can't raise — then
|
||||
the driver falls through to the shared poll engine.
|
||||
|
||||
Strings, arrays and bit-indexed BOOLs are registered as **raw bytes** at the PLC's
|
||||
resolved width and decoded by the driver, with the same rules the poll read uses
|
||||
(see [Strings](#strings-and-declared-type-checking) and [Array tags](#array-tags)).
|
||||
A tag the driver refuses to register — a declared-type mismatch (`BadTypeMismatch`)
|
||||
or an array over the 128 KiB notification cap (`BadOutOfRange`) — is isolated: it
|
||||
publishes that status once, logs one WARN, and the rest of the subscription
|
||||
registers. An ADS error at registration (e.g. a symbol that does not exist) still
|
||||
fails the whole subscribe call.
|
||||
|
||||
## Single-connection-per-device
|
||||
|
||||
Each device's ADS client is lazily connected and serialized by a per-device
|
||||
@@ -219,32 +228,121 @@ writes use the driver-wide `Timeout`.
|
||||
- **CLI** — [Driver.TwinCAT.Cli.md](../Driver.TwinCAT.Cli.md) documents the
|
||||
standalone read/write/browse/probe CLI for manual checks.
|
||||
|
||||
## 1-D array support
|
||||
## Array tags
|
||||
|
||||
A TwinCAT tag becomes a **1-D OPC UA array node** when its `TagConfig` JSON carries
|
||||
`"isArray": true` and `"arrayLength": N` (N ≥ 1). The canonical rule:
|
||||
`isArray: true` + `arrayLength >= 1` → array; `isArray: false` (any length) → scalar.
|
||||
A TwinCAT tag becomes a **1-D OPC UA array node** (ValueRank 1, `ArrayDimensions = [N]`) when
|
||||
its `TagConfig` JSON carries `"isArray": true` and `"arrayLength": N` (N ≥ 1). The canonical
|
||||
rule: `isArray: true` + `arrayLength >= 1` → array; `isArray: false` (any length) → scalar.
|
||||
`DataType` is the **element** type.
|
||||
|
||||
**Read mechanism** — the driver performs an **ADS native array symbol read** against the
|
||||
declared `SymbolPath`. ADS returns the full array value buffer for a symbol that maps to a
|
||||
TwinCAT array type (e.g. `ARRAY [0..9] OF REAL`); the driver reads `N` elements from the
|
||||
response.
|
||||
### One decode for poll and native notifications
|
||||
|
||||
⚠️ **Native notifications do not carry arrays.** The notification path registers the
|
||||
declared *scalar* type, so before the declared-type check an array tag subscribed natively
|
||||
published only its first element at Good. It is now refused with `BadTypeMismatch` (the PLC
|
||||
symbol is an array, not a scalar). Subscribe array tags with `UseNativeNotifications=false`
|
||||
(the poll engine uses the array read above) until native array notifications are added.
|
||||
An array is read — by a poll **and** by a native notification — as **raw bytes** at a layout
|
||||
planned from the PLC's **resolved** array type (`TwinCATTypeGuard.CheckArray`), and decoded by
|
||||
one codec (`TwinCATArrayCodec`). Both paths therefore publish the identical value: the
|
||||
element-typed CLR array (`bool[]`, `sbyte[]`, `byte[]`, `short[]`, `ushort[]`, `int[]`,
|
||||
`uint[]`, `long[]`, `ulong[]`, `float[]`, `double[]`, `string[]`; `TIME` / `DATE` / `DT` /
|
||||
`TOD` elements as `uint[]` — the same CLR shape their scalars publish), N elements long.
|
||||
|
||||
**Unit test coverage** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/` covers
|
||||
the array symbol read path via the fake ADS client factory. The TwinCAT integration
|
||||
fixture is down during normal dev.
|
||||
- **A native notification registers the whole array** (element stride × N) as raw bytes, so
|
||||
the PLC pushes the array on change and every notification decodes to the full array. Before
|
||||
this the notification path registered only the declared *scalar* — publishing element 0 at
|
||||
Good — and the declared-type check then refused every array tag. `UseNativeNotifications=false`
|
||||
is no longer needed for arrays.
|
||||
- **Elements are checked like a scalar notification**: the declared element type must match the
|
||||
PLC element's width, float/integer kind and string kind (`DInt` over `ARRAY OF REAL` → refused;
|
||||
`Int` over `ARRAY OF UINT` — same width, same kind — tolerated). `BYTE` elements map to `SInt`.
|
||||
- **STRING(n) / WSTRING(n) arrays** are laid out at the PLC's own element width (n+1 /
|
||||
2(n+1)); each element ends at its own first NUL and decodes with the same Windows-1252 /
|
||||
UTF-8 rules as a scalar string (see above).
|
||||
- **Multi-dimensional arrays** (`ARRAY [1..3, 1..2] OF REAL`) are published **flattened** in PLC
|
||||
memory order (the last index varies fastest); `arrayLength` is the total element count (6).
|
||||
**Lower bounds play no part**: a PLC `ARRAY [1..20]` is published 0-based, element 0 = PLC
|
||||
index 1.
|
||||
- **Sub-range:** an `arrayLength` **shorter** than the PLC array reads the **leading N
|
||||
elements** (N × stride bytes from the array start) — a smaller read and a smaller
|
||||
notification, and the value always matches the node's advertised `ArrayDimensions`. A
|
||||
**longer** `arrayLength` is refused (`BadTypeMismatch`) rather than reading past the array.
|
||||
- **Refused with `BadTypeMismatch`** (one WARN, that tag only — the rest of the subscription
|
||||
registers and stays Good): an element-type mismatch, a too-long `arrayLength`, an
|
||||
**array of structs / UDTs / FBs**, an array of pointers, a nested (jagged) array, an array tag
|
||||
over a non-array symbol, a `VAR_IN_OUT` array reference, and — on the read path too now — a
|
||||
**scalar** tag over a PLC array (author it as an array tag, or address one element). Before,
|
||||
a scalar poll over an array made the Beckhoff read throw and surfaced as
|
||||
`BadCommunicationError` with the driver Degraded.
|
||||
- **Element and bit paths stay scalars.** `Arr[3]` is a scalar tag on one element (native and
|
||||
poll). A bit-indexed BOOL (`MAIN.Flags.3`, `.ADSSTATE_RUN.0`) registers its notification on the
|
||||
**parent word** as raw bytes and reads the bit — ADS has no symbol for `WORD.N`, and
|
||||
registering one used to fail with `DeviceSymbolNotFound` and take the whole subscribe call
|
||||
down (found live, below). A bit outside the parent's width, or a bit of a non-integer parent,
|
||||
is refused with `BadTypeMismatch`.
|
||||
|
||||
**Live-verify** — integration-fixture-gated (requires the TwinCAT Docker fixture / AMS router).
|
||||
### Notification size cap
|
||||
|
||||
**Deferrals** — array *writes* (`ADS WriteValueAsync` for an array), multi-dimensional
|
||||
arrays, per-element historization. `IRediscoverable` fires and now surfaces a `/hosts` re-browse prompt, but still does not rebuild the address space (#518)
|
||||
on PLC re-download (unchanged semantics from scalar nodes).
|
||||
ADS documents no notification size limit, and a live TwinCAT 2.11 target registered and
|
||||
delivered a **131,072-byte** notification (`P_ETHERCATDIAGNOSIS.SLAVEADDRESS`,
|
||||
`ARRAY [0..65535] OF UINT`) without complaint. The driver still caps a native array at
|
||||
**128 KiB** (`TwinCATTypeGuard.MaxNotificationArrayBytes`, the largest size proven live): the
|
||||
PLC compares the whole buffer every cycle for an on-change notification, and every change is
|
||||
copied through the router and the driver's notification queue. A bigger array is refused for
|
||||
native subscription with **`BadOutOfRange`** (one WARN, that tag only) — author a shorter
|
||||
`arrayLength` (sub-range) or subscribe with `UseNativeNotifications=false`; poll reads have no
|
||||
cap.
|
||||
|
||||
### Why raw bytes, not the Beckhoff typed array read
|
||||
|
||||
The poll read used to ask the Beckhoff library for `elementType[]`. Probed live on Z33856, that
|
||||
failed for most real arrays: the CLR element type had to match the PLC's **exactly** (an
|
||||
`ARRAY OF TIME` as `uint[]` threw `ArgumentException`), a multi-dimensional array demanded a
|
||||
rank-N CLR array (`Single[,]`), and a dotted **member** array was sliced by its under-reported
|
||||
TwinCAT 2 symbol size (`P_INPUTVALUESFILTER.FBUSPWR1G1.SAMPLE`, `ARRAY [1..20] OF REAL`:
|
||||
symbol 10 B vs type 80 B → `ArgumentOutOfRangeException`). Each surfaced as
|
||||
`BadCommunicationError`. The raw-bytes model sizes everything from the resolved type, as the
|
||||
string path already does.
|
||||
|
||||
### Live validation (2026-10-07, TwinCAT 2.11 Elma cleaners)
|
||||
|
||||
Read-only, against Z33856 (`10.201.45.236.1.1:801`) and Z33733 (`5.25.53.46.1.1:801`) through
|
||||
an in-process `Beckhoff.TwinCAT.Ads.TcpRouter` scratch harness; one connection at a time, no
|
||||
writes. Browse found 594 / 583 array symbols (509 / 500 of primitives, 15 / 14 of strings,
|
||||
17 / 11 multi-dimensional, ~400 with a non-zero lower bound). Each tag was subscribed natively
|
||||
for 10 s alongside a 1 s poll read; native and poll matched in value, CLR element type and
|
||||
length for every array, and matched an independent Beckhoff typed read wherever that read
|
||||
works:
|
||||
|
||||
| Symbol | PLC type | Tag | Published |
|
||||
|---|---|---|---|
|
||||
| `.ARRFAULTCODE` | `ARRAY [0..12] OF UINT` | UInt × 13 | `UInt16[13]` (element 3 = 32, matching `.ARRFAULTCODE[3]` scalar) |
|
||||
| `.ARRUSPOWER` | `ARRAY [1..3, 1..2] OF REAL` / `[1..2, 1..2]` | Real × 6 / 4 | `Single[6]` / `Single[4]` — changing (~1060 W on Z33733); **23/23** notifications identical to an immediate poll |
|
||||
| `P_INPUTVALUESFILTER.FBUSPWR1G1.SAMPLE` | `ARRAY [1..20] OF REAL` (member, symbol 10 B) | Real × 20, and × 5 (sub-range) | `Single[20]` changing, **14/14** identical; `Single[5]` = its first 5 |
|
||||
| `.THROTTLE_MODE_DEFAULT` | `ARRAY [0..12] OF TIME` | Time × 13 | `UInt32[13]` `[0, 10, 20, 40, …]` ms |
|
||||
| `.ARGTYPE_IS_BIT_TYPE` | `ARRAY [0..18] OF BOOL` | Bool × 19 | `Boolean[19]` |
|
||||
| `.FORMAT_DEFAULT_PRECISION` / `.DWORD_DECADE_DIVIDER` / `.DIXTIMEALARM` | `INT` / `DWORD` / `UDINT` arrays | Int / UDInt | `Int16[12]` / `UInt32[10]` / `UInt32[101]` |
|
||||
| `.IMQN_TABLE` / `.AMSNETID` / `.ARRMODULENO` | `LREAL` / `USINT` / `BYTE` arrays | LReal / USInt / SInt | `Double[32]` / `Byte[6]` / `SByte[13]` (changing on Z33856) |
|
||||
| `FAST.COMPORTCONTROL.COMPORTDEBUGBUFFER.BUFFER` | `ARRAY [0..300] OF INT` (member) | Int × 301 | `Int16[301]`, changing every cycle; 18–19 of ~59 notifications identical to an immediate poll (the rest changed in between) |
|
||||
| `P_ALARMS.ARRSTATE` / `.ARRHEADER` | `ARRAY [1..n] OF STRING(80)` | String | `["RUN", "RUN", "RUN", "RUN", "MON_ON", "", …]` / `["Time1", "UsTime1 bottom", …]` |
|
||||
| `.ARRDATA` | `ARRAY [0..5, 0..17] OF STRING(80)` (8,748 B) | String × 108 | `String[108]` |
|
||||
| `P_ETHERCATDIAGNOSIS.SLAVEADDRESS` | `ARRAY [0..65535] OF UINT` (131,072 B) | UInt × 65536 | `UInt16[65536]` `[1001, 1002, …]` — at the cap |
|
||||
|
||||
Refused per tag with `BadTypeMismatch`, on both paths, while every sibling stayed Good: `DInt`
|
||||
over `.ARRUSPOWER` (REAL elements), `arrayLength` 14 over the 13-element `.ARRFAULTCODE`,
|
||||
`DInt` × 13 over `.ARRCARRIERDATA_V` (`ARRAY OF ST_CARRIERDATA`), scalar `UInt` over
|
||||
`.ARRFAULTCODE`, and an array of 2 over the scalar `.ADSSTATE_RUN`. The bit tag
|
||||
`.ADSSTATE_RUN.0` = TRUE natively (it previously failed to register at all).
|
||||
|
||||
### Coverage and deferrals
|
||||
|
||||
**Unit tests** — `TwinCATArrayCodecTests` (layout planning, element checks, sub-range, size
|
||||
cap, decode of every element type), `TwinCATNativeArraySubscribeTests` (the driver passes the
|
||||
authored length, replays it after a reconnect, isolates a refused tag with the refusal's own
|
||||
status), `TwinCATBitAccessTests`. The Beckhoff `AdsClient` cannot be faked, so the adapter
|
||||
stays a thin shell over these pure classes.
|
||||
|
||||
**Deferrals** — array *writes* (`ADS WriteValueAsync` for an array), per-element
|
||||
historization, and **controller browse of multi-dimensional arrays** (discovery still drops
|
||||
them; author such a tag by hand with `arrayLength` = the total element count).
|
||||
`IRediscoverable` fires and now surfaces a `/hosts` re-browse prompt, but still does not
|
||||
rebuild the address space (#518) on PLC re-download (unchanged semantics from scalar nodes).
|
||||
|
||||
See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
|
||||
and the UI authoring flow.
|
||||
|
||||
@@ -99,7 +99,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
// is inherently scalar — arrayCount does not apply.
|
||||
if (bitIndex is int bit && type == TwinCATDataType.Bool)
|
||||
{
|
||||
var parent = StripBitSuffix(symbolPath);
|
||||
var parent = TwinCATBitAccess.ParentPath(symbolPath);
|
||||
var parentResult = await _client.ReadValueAsync(parent, typeof(uint), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (parentResult.ErrorCode != AdsErrorCode.NoError)
|
||||
@@ -107,31 +107,29 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
return (ExtractBit(parentResult.Value, bit), TwinCATStatusMapper.Good);
|
||||
}
|
||||
|
||||
// Array read — ADS reads array symbols natively into a typed managed array. We ask
|
||||
// the SDK for the element-CLR-type's 1-D array (e.g. int[]); the .NET binding
|
||||
// marshals the whole array in one read. The returned value is already the boxed
|
||||
// element-typed array (int[] / float[] / bool[] / string[] / …), so the runtime's
|
||||
// OPC UA layer sees a proper 1-D array value. ASSUMPTION (no live ADS here):
|
||||
// AdsClient.ReadValueAsync(symbol, elementType.MakeArrayType(), ct) returns the
|
||||
// typed array — this matches Beckhoff's documented generic ReadValue<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
|
||||
// Resolve the PLC symbol first so the declared type can be checked against it. The symbol is
|
||||
// resolved through the Beckhoff symbol cache (the same lookup ReadValueAsync makes
|
||||
// internally), so this costs no extra round trip after the first read.
|
||||
var (shape, symbol, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
|
||||
var (shape, array, symbol, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (symbolStatus != TwinCATStatusMapper.Good) return (null, symbolStatus);
|
||||
|
||||
// Array read: raw bytes at the layout TwinCATTypeGuard.CheckArray plans from the RESOLVED
|
||||
// array type, decoded by TwinCATArrayCodec — the same decode a native array notification
|
||||
// runs, so poll and notification publish identical arrays. The Beckhoff typed array read this
|
||||
// replaced failed for most arrays on a live TC 2.11 target: the CLR element type had to match
|
||||
// exactly (ARRAY OF TIME as uint[] threw), a multi-dimensional array needed a rank-N CLR
|
||||
// array, and a member array was sliced by its under-reported symbol size.
|
||||
if (arrayCount is int count)
|
||||
{
|
||||
if (TwinCATTypeGuard.CheckArray(type, count, shape, array, out var layout) is not null)
|
||||
return (null, TwinCATStatusMapper.BadTypeMismatch);
|
||||
var (arrayBytes, arrayStatus) = await ReadRawAsync(symbolPath, symbol!, layout.ByteLength, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return arrayStatus == TwinCATStatusMapper.Good
|
||||
? (TwinCATArrayCodec.Decode(type, arrayBytes, layout, StringsAreUtf8()), TwinCATStatusMapper.Good)
|
||||
: (null, arrayStatus);
|
||||
}
|
||||
if (TwinCATTypeGuard.CheckRead(type, shape) is not null)
|
||||
return (null, TwinCATStatusMapper.BadTypeMismatch);
|
||||
|
||||
@@ -170,21 +168,34 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
/// <see cref="TwinCATTypeGuard"/> compares a declared type against. Uses the Beckhoff symbol
|
||||
/// cache (lookup on), so repeat calls are local.
|
||||
/// </summary>
|
||||
private async Task<(TwinCATPlcSymbolShape shape, IAdsSymbol? symbol, uint status)> DescribeSymbolAsync(
|
||||
string symbolPath, CancellationToken cancellationToken)
|
||||
private async Task<(TwinCATPlcSymbolShape shape, TwinCATPlcArrayShape? array, IAdsSymbol? symbol, uint status)>
|
||||
DescribeSymbolAsync(string symbolPath, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _client.ReadSymbolAsync(symbolPath, cancellationToken).ConfigureAwait(false);
|
||||
if (result.ErrorCode != AdsErrorCode.NoError)
|
||||
return (default, null, MapAndSignal((uint)result.ErrorCode));
|
||||
return (default, null, null, MapAndSignal((uint)result.ErrorCode));
|
||||
if (result.Value is not { } symbol)
|
||||
return (default, null, TwinCATStatusMapper.BadNodeIdUnknown);
|
||||
return (default, null, null, TwinCATStatusMapper.BadNodeIdUnknown);
|
||||
|
||||
// Resolve aliases (T_MaxString → STRING(255)) so the guard sees the base type, and take the
|
||||
// resolved type's byte size — a TwinCAT 2 member symbol's own ByteSize is under-reported.
|
||||
var dataType = symbol.DataType?.Resolve();
|
||||
var shape = TwinCATPlcSymbolShape.Classify(
|
||||
dataType?.Name ?? symbol.TypeName, dataType?.Category, symbol.ByteSize, dataType?.ByteSize, symbol.TypeName);
|
||||
return (shape, symbol, TwinCATStatusMapper.Good);
|
||||
return (shape, DescribeArray(dataType), symbol, TwinCATStatusMapper.Good);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
@@ -246,15 +257,6 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
? TwinCATStringCodec.DecodeWString(bytes)
|
||||
: TwinCATStringCodec.DecodeString(bytes, utf8);
|
||||
|
||||
private static string StripBitSuffix(string symbolPath)
|
||||
{
|
||||
var lastDot = symbolPath.LastIndexOf('.');
|
||||
if (lastDot < 0) return symbolPath;
|
||||
return int.TryParse(symbolPath.AsSpan(lastDot + 1), out _)
|
||||
? symbolPath[..lastDot]
|
||||
: symbolPath;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<uint> WriteValueAsync(
|
||||
string symbolPath,
|
||||
@@ -297,6 +299,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
int? arrayCount,
|
||||
TimeSpan cycleTime,
|
||||
int maxDelayMs,
|
||||
Action<string, object?> onChange,
|
||||
@@ -305,24 +308,59 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
var clrType = MapToClrType(type);
|
||||
int[]? anyTypeArgs = null;
|
||||
var stringsUtf8 = false;
|
||||
if (bitIndex is null || type != TwinCATDataType.Bool)
|
||||
TwinCATArrayLayout? arrayLayout = null;
|
||||
var registerPath = symbolPath;
|
||||
if (bitIndex is int bit && type == TwinCATDataType.Bool)
|
||||
{
|
||||
// ADS has no symbol for WORD.N (live on TC 2.11: DeviceSymbolNotFound for .ADSSTATE_RUN.0,
|
||||
// which took the whole subscribe call down). Register the PARENT word as raw bytes at its
|
||||
// resolved width and read the bit in OnAdsNotificationEx — the read path's model.
|
||||
registerPath = TwinCATBitAccess.ParentPath(symbolPath);
|
||||
var (parent, _, _, parentStatus) = await DescribeSymbolAsync(registerPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (parentStatus != TwinCATStatusMapper.Good)
|
||||
throw new InvalidOperationException(
|
||||
$"Resolving {registerPath} for a notification failed with status 0x{parentStatus:X8}");
|
||||
if (TwinCATBitAccess.CheckParent(bit, parent) is { } bitReason)
|
||||
throw new TwinCATTypeMismatchException(symbolPath, bitReason);
|
||||
clrType = typeof(byte[]);
|
||||
anyTypeArgs = [parent.ByteSize];
|
||||
}
|
||||
else
|
||||
{
|
||||
// A notification carries raw bytes decoded as the DECLARED type, so a declared type that
|
||||
// cannot decode the PLC symbol (width, float/integer or string kind differ) would publish
|
||||
// wrong values at Good. Refuse it here; the driver isolates the one tag to Bad.
|
||||
var (shape, _, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
|
||||
var (shape, array, _, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (symbolStatus != TwinCATStatusMapper.Good)
|
||||
throw new InvalidOperationException(
|
||||
$"Resolving {symbolPath} for a notification failed with status 0x{symbolStatus:X8}");
|
||||
if (TwinCATTypeGuard.CheckNotification(type, shape) is { } reason)
|
||||
|
||||
if (arrayCount is int count)
|
||||
{
|
||||
// An array registers the whole planned layout (element stride × authored length, from the
|
||||
// RESOLVED array type) as raw bytes and decodes it in OnAdsNotificationEx with the same
|
||||
// TwinCATArrayCodec a poll read uses — so both paths publish the identical array.
|
||||
if (TwinCATTypeGuard.CheckArray(type, count, shape, array, out var layout) is { } arrayReason)
|
||||
throw new TwinCATTypeMismatchException(symbolPath, arrayReason);
|
||||
if (TwinCATTypeGuard.CheckNotificationSize(layout) is { } sizeReason)
|
||||
throw new TwinCATNotificationRefusedException(symbolPath, sizeReason, TwinCATStatusMapper.BadOutOfRange);
|
||||
clrType = typeof(byte[]);
|
||||
anyTypeArgs = [layout.ByteLength];
|
||||
stringsUtf8 = StringsAreUtf8();
|
||||
arrayLayout = layout;
|
||||
}
|
||||
else if (TwinCATTypeGuard.CheckNotification(type, shape) is { } reason)
|
||||
{
|
||||
throw new TwinCATTypeMismatchException(symbolPath, reason);
|
||||
}
|
||||
|
||||
// A string has no fixed width: the "any" type string needs an explicit length (the
|
||||
// Beckhoff marshaller throws ArgumentNullException without one), and a STRING(n) is n+1
|
||||
// bytes with n chosen per declaration. Register the PLC's own byte width as raw bytes
|
||||
// and decode them in OnAdsNotificationEx with the same rules as a read.
|
||||
if (type is TwinCATDataType.String or TwinCATDataType.WString)
|
||||
if (arrayLayout is null && type is TwinCATDataType.String or TwinCATDataType.WString)
|
||||
{
|
||||
clrType = typeof(byte[]);
|
||||
anyTypeArgs = [shape.ByteSize];
|
||||
@@ -337,23 +375,24 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
// with the handle as part of the event args so we use the handle as the correlation
|
||||
// key into _notifications.
|
||||
var result = await _client.AddDeviceNotificationExAsync(
|
||||
symbolPath, settings, userData: null, clrType, anyTypeArgs, cancellationToken)
|
||||
registerPath, settings, userData: null, clrType, anyTypeArgs, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (result.ErrorCode != AdsErrorCode.NoError)
|
||||
throw new InvalidOperationException(
|
||||
$"AddDeviceNotificationExAsync failed with ADS error {result.ErrorCode} for {symbolPath}");
|
||||
$"AddDeviceNotificationExAsync failed with ADS error {result.ErrorCode} for {registerPath}");
|
||||
|
||||
var reg = new NotificationRegistration(symbolPath, type, bitIndex, onChange, this, result.Handle)
|
||||
{
|
||||
StringsUtf8 = stringsUtf8,
|
||||
ArrayLayout = arrayLayout,
|
||||
};
|
||||
_notifications[result.Handle] = reg;
|
||||
return reg;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs on the <see cref="AdsClient"/> AMS router thread. Does the cheap bit-extraction
|
||||
/// decode then enqueues — no driver logic, no consumer callbacks — so a slow consumer
|
||||
/// Runs on the <see cref="AdsClient"/> AMS router thread. Does the cheap decode (bit
|
||||
/// extraction, string, array) then enqueues — no driver logic, no consumer callbacks — so a slow consumer
|
||||
/// can never stall ADS notification delivery for the rest of the process.
|
||||
/// Drops the notification (DropWrite) if the queue is saturated.
|
||||
/// </summary>
|
||||
@@ -361,8 +400,16 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
{
|
||||
if (!_notifications.TryGetValue(args.Handle, out var reg)) return;
|
||||
var value = args.Value;
|
||||
if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool && value is not bool)
|
||||
value = ExtractBit(value, bit);
|
||||
if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool)
|
||||
{
|
||||
if (value is not byte[] word || bit / 8 >= word.Length) return;
|
||||
value = TwinCATBitAccess.ReadBit(word, bit);
|
||||
}
|
||||
else if (reg.ArrayLayout is { } layout && value is byte[] arrayBytes)
|
||||
{
|
||||
try { value = TwinCATArrayCodec.Decode(reg.Type, arrayBytes, layout, reg.StringsUtf8); }
|
||||
catch (ArgumentException) { return; } // a short buffer is never published as a short array
|
||||
}
|
||||
else if (value is byte[] raw && reg.Type is TwinCATDataType.String or TwinCATDataType.WString)
|
||||
value = DecodeString(reg.Type, raw, reg.StringsUtf8);
|
||||
_notificationQueue.Writer.TryWrite(new PendingNotification(reg, value));
|
||||
@@ -615,6 +662,11 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
/// </summary>
|
||||
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>
|
||||
/// Disposes the notification subscription.
|
||||
/// </summary>
|
||||
|
||||
@@ -41,9 +41,11 @@ public interface ITwinCATClient : IDisposable
|
||||
/// <param name="symbolPath">The ADS symbol path.</param>
|
||||
/// <param name="type">The target data type.</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"/>
|
||||
/// elements; the boxed value is the element-typed CLR array (<c>int[]</c> / <c>float[]</c> /
|
||||
/// <c>bool[]</c> / <c>string[]</c> / …). When null, read a scalar (Phase 4c).</param>
|
||||
/// <param name="arrayCount">When non-null, read an array of this many <paramref name="type"/>
|
||||
/// elements (a multi-dimensional PLC array flattened in memory order; a count shorter than the PLC
|
||||
/// array reads its leading sub-range); the boxed value is the element-typed CLR array
|
||||
/// (<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>
|
||||
/// <returns>The boxed value read (or <c>null</c>) together with the mapped OPC UA status.</returns>
|
||||
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="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="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="maxDelayMs">Maximum batching delay in milliseconds — TwinCAT may coalesce
|
||||
/// 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="cancellationToken">Cancels the initial registration; does not tear down an established notification.</param>
|
||||
/// <returns>A handle whose disposal tears the registered notification down.</returns>
|
||||
/// <exception cref="TwinCATTypeMismatchException">The declared <paramref name="type"/> cannot decode
|
||||
/// the PLC symbol (width, float/integer or string kind differ) — the caller isolates that one tag
|
||||
/// to Bad rather than registering a notification that would publish wrong bytes at Good.</exception>
|
||||
/// <exception cref="TwinCATNotificationRefusedException">The notification is refused for this one tag
|
||||
/// — the declared <paramref name="type"/> / <paramref name="arrayCount"/> cannot decode the PLC
|
||||
/// 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(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
int? arrayCount,
|
||||
TimeSpan cycleTime,
|
||||
int maxDelayMs,
|
||||
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 parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
||||
// An array-typed tag (def.ArrayLength != null) drives a native 1-D ADS array read;
|
||||
// the boxed result is an element-typed CLR array. Scalar tags pass null
|
||||
// (scalar path) — bit-indexed BOOLs are inherently scalar so the client ignores
|
||||
// arrayCount when a bitIndex is present (Phase 4c).
|
||||
// An array-typed tag (def.ArrayLength != null) reads the array as raw bytes and decodes
|
||||
// it with TwinCATArrayCodec — the same decode a native array notification runs — into an
|
||||
// element-typed CLR array. Scalar tags pass null (scalar path) — bit-indexed BOOLs are
|
||||
// inherently scalar so the client ignores arrayCount when a bitIndex is present.
|
||||
var (value, status) = await client.ReadValueAsync(
|
||||
symbolName, def.DataType, parsed?.BitIndex, def.ArrayLength, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
@@ -279,7 +279,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
if (status == TwinCATStatusMapper.BadTypeMismatch)
|
||||
_logger.LogWarning(
|
||||
"TwinCAT driver '{DriverInstanceId}' refused '{Reference}': declared {DataType} " +
|
||||
"cannot decode the PLC symbol '{Symbol}' (string/numeric kind mismatch or a reference/container)",
|
||||
"cannot decode the PLC symbol '{Symbol}' (string/numeric kind mismatch, array element type " +
|
||||
"or length mismatch, or a reference/container)",
|
||||
_driverInstanceId, reference, def.DataType, symbolName);
|
||||
else
|
||||
_logger.LogWarning(
|
||||
@@ -531,7 +532,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
// client swap unchanged — replay re-registers the SAME intent onto the new client.
|
||||
var reg = new NativeRegistration(
|
||||
Interlocked.Increment(ref _nextRegId), device, reference, symbolName,
|
||||
def.DataType, bitIndex, publishingInterval,
|
||||
def.DataType, bitIndex, def.ArrayLength, publishingInterval,
|
||||
onChange: (_, value) => OnDataChange?.Invoke(this,
|
||||
new DataChangeEventArgs(handle, reference, new DataValueSnapshot(
|
||||
value, TwinCATStatusMapper.Good, DateTime.UtcNow, DateTime.UtcNow))),
|
||||
@@ -545,17 +546,19 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
{
|
||||
await RegisterNotificationAsync(device, reg, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (TwinCATTypeMismatchException mismatch)
|
||||
catch (TwinCATNotificationRefusedException refused)
|
||||
{
|
||||
// The declared type cannot decode this PLC symbol (ignitionoee TwinCAT-002/-008):
|
||||
// isolate THIS tag to Bad — one WARN, one BadTypeMismatch snapshot — and keep the
|
||||
// rest of the subscription. Its intent stays on the device with no live handle,
|
||||
// so the probe-tick retry re-checks it after a PLC program change.
|
||||
// The declared type / array length cannot decode this PLC symbol (ignitionoee
|
||||
// TwinCAT-002/-008), or the array is over the native-notification size cap: isolate
|
||||
// THIS tag to Bad — one WARN, one snapshot carrying the refusal's status
|
||||
// (BadTypeMismatch / BadOutOfRange) — and keep the rest of the subscription. Its
|
||||
// intent stays on the device with no live handle, so the probe-tick retry re-checks
|
||||
// it after a PLC program change.
|
||||
_logger.LogWarning(
|
||||
"TwinCAT driver '{DriverInstanceId}' refused native notification for '{Reference}': {Reason}",
|
||||
_driverInstanceId, reference, mismatch.Message);
|
||||
_driverInstanceId, reference, refused.Message);
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, reference, new DataValueSnapshot(
|
||||
null, TwinCATStatusMapper.BadTypeMismatch, null, DateTime.UtcNow)));
|
||||
null, refused.StatusCode, null, DateTime.UtcNow)));
|
||||
}
|
||||
registrations.Add(reg);
|
||||
}
|
||||
@@ -594,10 +597,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
try
|
||||
{
|
||||
newHandle = await client.AddNotificationAsync(
|
||||
reg.SymbolName, reg.DataType, reg.BitIndex, reg.Interval,
|
||||
reg.SymbolName, reg.DataType, reg.BitIndex, reg.ArrayLength, reg.Interval,
|
||||
_options.NotificationMaxDelayMs, reg.OnChange, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (TwinCATTypeMismatchException)
|
||||
catch (TwinCATNotificationRefusedException)
|
||||
{
|
||||
// Keep the intent with no live handle (a "dead" registration) so the probe-tick
|
||||
// retry re-checks it and UnsubscribeAsync still removes it; the caller isolates it.
|
||||
@@ -659,6 +662,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
public TwinCATDataType DataType { get; }
|
||||
/// <summary>Bit index for a BOOL-within-word symbol; null otherwise.</summary>
|
||||
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>
|
||||
public TimeSpan Interval { get; }
|
||||
/// <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="dataType">Declared data type.</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="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>
|
||||
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)
|
||||
{
|
||||
Id = id;
|
||||
@@ -700,6 +706,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
SymbolName = symbolName;
|
||||
DataType = dataType;
|
||||
BitIndex = bitIndex;
|
||||
ArrayLength = arrayLength;
|
||||
Interval = interval;
|
||||
OnChange = onChange;
|
||||
OnUnavailable = onUnavailable;
|
||||
@@ -969,7 +976,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
try
|
||||
{
|
||||
var newHandle = await client.AddNotificationAsync(
|
||||
reg.SymbolName, reg.DataType, reg.BitIndex, reg.Interval,
|
||||
reg.SymbolName, reg.DataType, reg.BitIndex, reg.ArrayLength, reg.Interval,
|
||||
_options.NotificationMaxDelayMs, reg.OnChange, ct).ConfigureAwait(false);
|
||||
var old = reg.SwapHandle(newHandle);
|
||||
try { old?.Dispose(); } catch { /* dead handle from the disposed client */ }
|
||||
@@ -1034,7 +1041,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
try
|
||||
{
|
||||
var newHandle = await client.AddNotificationAsync(
|
||||
reg.SymbolName, reg.DataType, reg.BitIndex, reg.Interval,
|
||||
reg.SymbolName, reg.DataType, reg.BitIndex, reg.ArrayLength, reg.Interval,
|
||||
_options.NotificationMaxDelayMs, reg.OnChange, ct).ConfigureAwait(false);
|
||||
reg.SwapHandle(newHandle);
|
||||
// STAB-17 ownership re-check — see ReplayNativeRegistrationsAsync.
|
||||
|
||||
@@ -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="IsFloat">True for a REAL / LREAL scalar.</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 =
|
||||
new(@"^(W?)STRING(\s*\(\s*\d+\s*\))?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
@@ -71,13 +73,39 @@ internal readonly record struct TwinCATPlcSymbolShape(TwinCATPlcKind Kind, int B
|
||||
|| name.StartsWith("POINTER TO", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.StartsWith("REFERENCE TO", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.Contains("VAR_IN_OUT", StringComparison.OrdinalIgnoreCase))
|
||||
return new(TwinCATPlcKind.NonScalar, byteSize, false, name);
|
||||
return new(TwinCATPlcKind.NonScalar, byteSize, false, name,
|
||||
IsArray: category == DataTypeCategory.Array || name.StartsWith("ARRAY", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var mapped = AdsTwinCATClient.MapSymbolTypeName(name);
|
||||
return new(TwinCATPlcKind.Scalar, byteSize, mapped is TwinCATDataType.Real or TwinCATDataType.LReal, name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// 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
|
||||
@@ -90,11 +118,14 @@ internal readonly record struct TwinCATPlcSymbolShape(TwinCATPlcKind Kind, int B
|
||||
/// <list type="bullet">
|
||||
/// <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
|
||||
/// 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>
|
||||
/// 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), 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
|
||||
/// DECLARED type, so a width mismatch truncates/over-reads and a same-width float/integer clash
|
||||
/// 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>
|
||||
/// String-ness is a KIND (TwinCAT-008): a STRING/WSTRING tag matches only a PLC string of the SAME
|
||||
/// kind, at any declared length (0.19.2). A same-width, same-kind numeric divergence
|
||||
@@ -117,6 +148,9 @@ internal static class TwinCATTypeGuard
|
||||
: $"declared {declared} but the PLC symbol is {plc.TypeName} ({plc.ByteSize} B) — a string tag " +
|
||||
$"matches only a PLC {declaredKind.Value.ToString().ToUpperInvariant()} of any length";
|
||||
}
|
||||
if (plc.IsArray)
|
||||
return $"declared scalar {declared} but the PLC symbol is the array {plc.TypeName} ({plc.ByteSize} B) — " +
|
||||
"author it as an array tag (isArray + arrayLength) or address one element (Arr[i])";
|
||||
return plc.Kind is TwinCATPlcKind.String or TwinCATPlcKind.WString
|
||||
? $"declared {declared} but the PLC symbol is the string {plc.TypeName} — declare it String/WString"
|
||||
: null;
|
||||
@@ -144,6 +178,72 @@ internal static class TwinCATTypeGuard
|
||||
: $"declared {declared} but the PLC symbol is {plc.TypeName} — float/integer kinds differ";
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <param name="type">The declared type.</param>
|
||||
/// <returns>The width in bytes.</returns>
|
||||
|
||||
@@ -2,21 +2,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown by <see cref="ITwinCATClient.AddNotificationAsync"/> when a tag's declared
|
||||
/// <see cref="TwinCATDataType"/> cannot decode the PLC symbol it names (see
|
||||
/// <see cref="TwinCATTypeGuard"/>). The driver isolates the one tag to
|
||||
/// <see cref="TwinCATStatusMapper.BadTypeMismatch"/> and keeps the rest of the subscription.
|
||||
/// <see cref="TwinCATDataType"/> (or, for an array tag, its element type / <c>ArrayLength</c>) cannot
|
||||
/// decode the PLC symbol it names (see <see cref="TwinCATTypeGuard"/>). The driver isolates the one
|
||||
/// tag to <see cref="TwinCATStatusMapper.BadTypeMismatch"/> and keeps the rest of the subscription.
|
||||
/// </summary>
|
||||
internal sealed class TwinCATTypeMismatchException : Exception
|
||||
internal sealed class TwinCATTypeMismatchException : TwinCATNotificationRefusedException
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="TwinCATTypeMismatchException"/> class.</summary>
|
||||
/// <param name="symbolPath">The ADS symbol the tag names.</param>
|
||||
/// <param name="reason">The guard's one-line reason.</param>
|
||||
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="type">The data type of the symbol.</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="maxDelayMs">The maximum delay in milliseconds.</param>
|
||||
/// <param name="onChange">The callback to invoke on value change.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task that returns a notification handle.</returns>
|
||||
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)
|
||||
{
|
||||
if (ThrowOnAddNotification)
|
||||
@@ -147,7 +148,7 @@ internal class FakeTwinCATClient : ITwinCATClient
|
||||
await gate.Task.ConfigureAwait(false);
|
||||
|
||||
LastMaxDelayMs = maxDelayMs;
|
||||
var reg = new FakeNotification(symbolPath, type, bitIndex, onChange, this);
|
||||
var reg = new FakeNotification(symbolPath, type, bitIndex, onChange, this) { ArrayCount = arrayCount };
|
||||
Notifications.Add(reg);
|
||||
return reg;
|
||||
}
|
||||
@@ -195,6 +196,8 @@ internal class FakeTwinCATClient : ITwinCATClient
|
||||
public TwinCATDataType Type { get; } = type;
|
||||
/// <summary>Gets the optional bit index.</summary>
|
||||
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>
|
||||
public Action<string, object?> OnChange { get; } = onChange;
|
||||
/// <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();
|
||||
}
|
||||
}
|
||||
+137
@@ -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");
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -191,13 +191,13 @@ public sealed class TwinCATNativeNotificationTests
|
||||
|
||||
/// <inheritdoc />
|
||||
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)
|
||||
{
|
||||
AddCallCount++;
|
||||
if (AddCallCount > _succeedBefore)
|
||||
throw new InvalidOperationException($"fake fail on call #{AddCallCount}");
|
||||
return base.AddNotificationAsync(symbolPath, type, bitIndex, cycleTime, maxDelayMs, onChange, cancellationToken);
|
||||
return base.AddNotificationAsync(symbolPath, type, bitIndex, arrayCount, cycleTime, maxDelayMs, onChange, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,26 @@ public sealed class TwinCATTypeGuardTests
|
||||
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>
|
||||
/// 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.
|
||||
|
||||
Reference in New Issue
Block a user