feat(twincat): native ADS notifications for array tags, decoded from raw bytes
An array tag subscribed natively used to register only its declared scalar (publishing element 0 at Good) and was then refused wholesale with BadTypeMismatch. The driver now hands the authored ArrayLength to the client, which registers the whole array as raw bytes and decodes every notification. - TwinCATTypeGuard.CheckArray plans the layout from the RESOLVED array type: element checked like a scalar notification (width / float kind / string kind); STRING(n)/WSTRING(n) elements stride n+1 / 2(n+1); multi-dim arrays flattened in PLC memory order; an authored ArrayLength shorter than the PLC array reads the leading sub-range, a longer one is refused; struct / pointer / nested-array elements, non-array symbols and VAR_IN_OUT refs are refused. - CheckNotificationSize caps a native array at 128 KiB (largest registration proven on a live TC 2.11 target); above it the tag is refused BadOutOfRange. - TwinCATArrayCodec decodes the bytes into the element-typed CLR array. - The poll read uses the same plan + codec instead of the Beckhoff typed array read, which failed live for most real arrays (exact CLR element type, rank-N for multi-dim, member arrays sliced by the under-reported TC2 symbol size). - TwinCATNotificationRefusedException carries the refusal's status; the type mismatch derives from it and the driver isolates either per tag.
This commit is contained in:
@@ -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>
|
||||
@@ -297,6 +308,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
int? arrayCount,
|
||||
TimeSpan cycleTime,
|
||||
int maxDelayMs,
|
||||
Action<string, object?> onChange,
|
||||
@@ -305,24 +317,42 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
var clrType = MapToClrType(type);
|
||||
int[]? anyTypeArgs = null;
|
||||
var stringsUtf8 = false;
|
||||
TwinCATArrayLayout? arrayLayout = null;
|
||||
if (bitIndex is null || type != TwinCATDataType.Bool)
|
||||
{
|
||||
// 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];
|
||||
@@ -346,6 +376,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
var reg = new NotificationRegistration(symbolPath, type, bitIndex, onChange, this, result.Handle)
|
||||
{
|
||||
StringsUtf8 = stringsUtf8,
|
||||
ArrayLayout = arrayLayout,
|
||||
};
|
||||
_notifications[result.Handle] = reg;
|
||||
return reg;
|
||||
@@ -363,6 +394,11 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
var value = args.Value;
|
||||
if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool && value is not bool)
|
||||
value = ExtractBit(value, 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 +651,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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -78,6 +78,31 @@ internal readonly record struct TwinCATPlcSymbolShape(TwinCATPlcKind Kind, int B
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
@@ -132,7 +157,10 @@ internal static class TwinCATTypeGuard
|
||||
if (reason is not null || StringKindOf(declared) is not null) return reason;
|
||||
|
||||
if (plc.Kind == TwinCATPlcKind.NonScalar)
|
||||
return $"declared scalar {declared} but the PLC symbol is {plc.TypeName} ({plc.ByteSize} B) — not a scalar";
|
||||
return $"declared scalar {declared} but the PLC symbol is {plc.TypeName} ({plc.ByteSize} B) — not a scalar" +
|
||||
(plc.TypeName.StartsWith("ARRAY", StringComparison.OrdinalIgnoreCase)
|
||||
? " (author it as an array tag: isArray + arrayLength)"
|
||||
: string.Empty);
|
||||
|
||||
var size = DeclaredByteSize(declared);
|
||||
if (size != plc.ByteSize)
|
||||
@@ -144,6 +172,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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user