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:
@@ -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);
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user