diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipArrayCodec.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipArrayCodec.cs
new file mode 100644
index 00000000..bb6455ab
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipArrayCodec.cs
@@ -0,0 +1,80 @@
+using System.Buffers.Binary;
+using System.Collections;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+///
+/// Encodes a whole-array write (review 07 U10): count elements at the element type's wire width,
+/// little-endian, exactly the layout a Logix atomic array holds and the read path decodes. Shared by both
+/// runtimes so the bytes on the wire are transport-independent.
+///
+///
+/// Element values CONVERT, they are not exact-matched. ScadaBridge (and any OPC UA client that
+/// round-trips what it read) writes CLR lists such as int[] to an INT array — the read path itself
+/// publishes INT/SINT elements as int — so each element goes through the same the
+/// scalar path uses. A value that does not fit is an (BadOutOfRange) and one that
+/// cannot convert a / (BadTypeMismatch), all
+/// thrown before any byte is sent.
+/// BOOL arrays (bit-packed DWORDs — a byte-per-element encode would clobber neighbour bits), STRING arrays
+/// (structures) and structure arrays are not encodable here; the driver refuses them BadNotSupported
+/// before a runtime exists.
+///
+internal static class AbCipArrayCodec
+{
+ /// Wire width of one element of an encodable array type, or null when the type is not encodable.
+ /// The element type.
+ /// The element width in bytes, or null .
+ public static int? ElementWidth(AbCipDataType type) => type switch
+ {
+ AbCipDataType.SInt or AbCipDataType.USInt => 1,
+ AbCipDataType.Int or AbCipDataType.UInt => 2,
+ AbCipDataType.DInt or AbCipDataType.UDInt or AbCipDataType.Real or AbCipDataType.Dt => 4,
+ AbCipDataType.LInt or AbCipDataType.ULInt or AbCipDataType.LReal => 8,
+ _ => null,
+ };
+
+ ///
+ /// The elements of an array write value, or null when the value is not a list (a scalar, a string, null).
+ /// Any non-string is accepted — int[] , short[] , List<object> …
+ ///
+ /// The client's write value.
+ /// The elements, or null .
+ public static object?[]? Elements(object? value) => value switch
+ {
+ null or string => null,
+ IEnumerable list => [.. list.Cast()],
+ _ => null,
+ };
+
+ /// Encodes as elements.
+ /// The element type (must have an ).
+ /// The element values; their count is the element count.
+ /// The little-endian wire bytes, values.Count × ElementWidth(type) long.
+ /// The type is not encodable.
+ public static byte[] Encode(AbCipDataType type, IReadOnlyList values)
+ {
+ var width = ElementWidth(type)
+ ?? throw new NotSupportedException($"Array writes of {type} elements are not supported.");
+ var buffer = new byte[values.Count * width];
+ for (var i = 0; i < values.Count; i++)
+ {
+ var v = values[i];
+ var span = buffer.AsSpan(i * width, width);
+ switch (type)
+ {
+ case AbCipDataType.SInt: span[0] = unchecked((byte)Convert.ToSByte(v)); break;
+ case AbCipDataType.USInt: span[0] = Convert.ToByte(v); break;
+ case AbCipDataType.Int: BinaryPrimitives.WriteInt16LittleEndian(span, Convert.ToInt16(v)); break;
+ case AbCipDataType.UInt: BinaryPrimitives.WriteUInt16LittleEndian(span, Convert.ToUInt16(v)); break;
+ case AbCipDataType.DInt:
+ case AbCipDataType.Dt: BinaryPrimitives.WriteInt32LittleEndian(span, Convert.ToInt32(v)); break;
+ case AbCipDataType.UDInt: BinaryPrimitives.WriteUInt32LittleEndian(span, Convert.ToUInt32(v)); break;
+ case AbCipDataType.LInt: BinaryPrimitives.WriteInt64LittleEndian(span, Convert.ToInt64(v)); break;
+ case AbCipDataType.ULInt: BinaryPrimitives.WriteUInt64LittleEndian(span, Convert.ToUInt64(v)); break;
+ case AbCipDataType.Real: BinaryPrimitives.WriteSingleLittleEndian(span, Convert.ToSingle(v)); break;
+ case AbCipDataType.LReal: BinaryPrimitives.WriteDoubleLittleEndian(span, Convert.ToDouble(v)); break;
+ }
+ }
+ return buffer;
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
index 0a7eb3b0..43afe57f 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
@@ -1376,6 +1376,43 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
string? rmwParent = null;
+ // U10: an array tag writes the WHOLE array. Shape is checked before any runtime exists, so a refused
+ // write creates, reads and sends nothing; a refusal is not a fault — no health change.
+ object?[]? arrayElements = null;
+ if (IsArrayTag(def))
+ {
+ var count = Math.Max(1, def.ElementCount);
+ uint? shapeRefusal = null;
+ string reason = "";
+ if (AbCipArrayCodec.ElementWidth(def.DataType) is null)
+ {
+ shapeRefusal = AbCipStatusMapper.BadNotSupported;
+ reason = $"{def.DataType} arrays cannot be written element-wise (BOOL arrays are bit-packed DWORDs, STRING/structure elements are structures)";
+ }
+ else if (AbCipArrayCodec.Elements(w.Value) is not { } elements)
+ {
+ shapeRefusal = AbCipStatusMapper.BadTypeMismatch;
+ reason = $"the value is not an array ({w.Value?.GetType().Name ?? "null"})";
+ }
+ else if (elements.Length != count)
+ {
+ shapeRefusal = AbCipStatusMapper.BadOutOfRange;
+ reason = $"{elements.Length} element(s) written to a {count}-element array";
+ }
+ else
+ {
+ arrayElements = elements;
+ }
+ if (shapeRefusal is { } refusedShape)
+ {
+ results[i] = new WriteResult(refusedShape);
+ WarnWriteRefusedOnce(def.Name,
+ "AbCip array write to {Tag} on device {Device} refused: {Reason} (logged once per tag)",
+ w.FullReference, def.DeviceHostAddress, reason);
+ continue;
+ }
+ }
+
try
{
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
@@ -1413,10 +1450,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// BOOL-array DWORD wrote all 32 bits. Discovered UDT members are where a mis-authored type
// is most likely (browse-commit fills dataType from the template, an operator may retype).
size = runtime.GetSize();
- refusal = WriteWidthRefusal(def, parsedPath?.BitIndex, size, runtimeTypesBool: runtime is NativeAbCipTagRuntime);
+ refusal = arrayElements is not null
+ ? ArrayWriteWidthRefusal(def, size)
+ : WriteWidthRefusal(def, parsedPath?.BitIndex, size, runtimeTypesBool: runtime is NativeAbCipTagRuntime);
if (refusal is null)
{
- runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value);
+ if (arrayElements is not null) runtime.EncodeArray(def.DataType, arrayElements);
+ else runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value);
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
status = runtime.GetStatus();
}
@@ -1436,7 +1476,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
results[i] = new WriteResult(refused);
WarnTypeMismatchOnce(def.Name,
"AbCip write to {Tag} on device {Device} refused before sending: the controller holds {Size} byte(s) there but the authored {DataType} encodes {Width} (logged once per tag)",
- w.FullReference, def.DeviceHostAddress, size, def.DataType, ExpectedScalarWriteWidth(def.DataType));
+ w.FullReference, def.DeviceHostAddress, size, def.DataType,
+ arrayElements is not null
+ ? AbCipArrayCodec.ElementWidth(def.DataType) * arrayElements.Length
+ : ExpectedScalarWriteWidth(def.DataType));
}
else if (status == (int)AbStatus.TypeMismatch)
{
@@ -2055,6 +2098,20 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
: AbCipStatusMapper.BadTypeMismatch;
}
+ ///
+ /// The refusal for a whole-array write whose authored shape does not fit what the controller holds: the last
+ /// read returned bytes, which must be the element width × the element count. Unknown
+ /// size (<= 0 ) is not judged here (the native runtime re-checks against the controller's type code).
+ ///
+ /// The array tag being written.
+ /// The runtime's read-buffer size in bytes.
+ /// BadTypeMismatch , or null to write.
+ internal static uint? ArrayWriteWidthRefusal(AbCipTagDefinition def, int size)
+ {
+ if (size <= 0 || AbCipArrayCodec.ElementWidth(def.DataType) is not { } width) return null;
+ return size == width * Math.Max(1, def.ElementCount) ? null : AbCipStatusMapper.BadTypeMismatch;
+ }
+
///
/// Recursively fan a controller-discovered UDT shape out into Variable nodes — one per
/// ATOMIC leaf — under the folder produced by . Mirrors the
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
index 1cf3c2e5..ad8b00a2 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
@@ -85,6 +85,17 @@ public interface IAbCipTagRuntime : IDisposable
/// Bit index for BOOL-within-DINT insertion, or null.
/// Value to encode.
void EncodeValue(AbCipDataType type, int? bitIndex, object? value);
+
+ ///
+ /// Encode a WHOLE-array write of (values.Count elements of
+ /// , at the element width — see ) into the local buffer.
+ /// Callers pair this with . A runtime that cannot write arrays throws
+ /// .
+ ///
+ /// The array element type.
+ /// The element values; already length-checked against the tag's element count.
+ void EncodeArray(AbCipDataType type, IReadOnlyList values) =>
+ throw new NotSupportedException($"{GetType().Name} does not support array writes.");
}
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
index 2e6cd7fc..07ac008e 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
@@ -235,6 +235,23 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
_ => 4,
};
+ ///
+ ///
+ /// The codec's bytes are copied into the buffer the last read sized (elem_count elements), so the write
+ /// sends exactly the new array — never a stale tail. A buffer smaller than the encoded array is refused
+ /// ( ) before anything is changed.
+ ///
+ public void EncodeArray(AbCipDataType type, IReadOnlyList values) =>
+ LibplctagStatusTranslation.WrapSync(() =>
+ {
+ var bytes = AbCipArrayCodec.Encode(type, values);
+ var size = _tag.GetSize();
+ if (size < bytes.Length)
+ throw new AbTransportException(AbStatus.TooSmall,
+ $"Array write to '{_tag.Name}': {bytes.Length} byte(s) encoded but the tag buffer holds {size}.");
+ for (var i = 0; i < bytes.Length; i++) _tag.SetUInt8(i, bytes[i]);
+ });
+
///
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value) =>
LibplctagStatusTranslation.WrapSync(() => EncodeValueCore(type, bitIndex, value));
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs
index 917f205b..a77179f9 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs
@@ -55,6 +55,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
private AbStatus _status = AbStatus.Pending;
private byte[]? _writeBuffer;
+ private ushort _writeElements = 1;
private string? _pendingString;
private bool _disposed;
@@ -233,17 +234,18 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
// Width is checked only for codes with a known width: DATE / DATE_AND_TIME (0xCD/0xCF) and
// Micro800 BYTE/WORD/LWORD (0xD1/0xD2/0xD4) are outside LogixTypeCode's table and write as today.
var width = LogixTypeCode.ByteWidth(type);
- if (width > 0 && width != buffer.Length)
+ if (width > 0 && width * _writeElements != buffer.Length)
{
// An authored BOOL over a packed BOOL-array DWORD: writing the DWORD would clobber the
// 31 sibling bools (libplctag does exactly that), so refuse it as a config error.
- if (type == LogixTypeCode.BoolArray)
+ if (type == LogixTypeCode.BoolArray && _writeElements == 1)
throw new NotSupportedException(
$"Write to '{_p.TagName}': a BOOL-array element (type 0xD3 DWORD) cannot be written as a single BOOL.");
throw new AbTransportException(AbStatus.TypeMismatch,
- $"Write to '{_p.TagName}': encoded {buffer.Length} byte(s) but the controller's type 0x{type:X4} is {width} byte(s) wide.");
+ $"Write to '{_p.TagName}': encoded {buffer.Length} byte(s) for {_writeElements} element(s) but the controller's type 0x{type:X4} is {width} byte(s) wide.");
}
- await client.WriteAsync(path, type, null, 1, buffer, cancellationToken).ConfigureAwait(false);
+ // U10: an array write is ONE Write Tag of _writeElements elements (the client fragments it if large).
+ await client.WriteAsync(path, type, null, _writeElements, buffer, cancellationToken).ConfigureAwait(false);
}
else
{
@@ -327,10 +329,22 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
};
}
+ ///
+ public void EncodeArray(AbCipDataType type, IReadOnlyList values)
+ {
+ ArgumentNullException.ThrowIfNull(values);
+ _writeBuffer = null;
+ _pendingString = null;
+ var buffer = AbCipArrayCodec.Encode(type, values); // throws (nothing staged) before any state changes
+ _writeElements = (ushort)Math.Clamp(values.Count, 1, ushort.MaxValue);
+ _writeBuffer = buffer;
+ }
+
///
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
{
_writeBuffer = null;
+ _writeElements = 1;
_pendingString = null;
switch (type)
{
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayWriteTests.cs
new file mode 100644
index 00000000..ea908d12
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayWriteTests.cs
@@ -0,0 +1,178 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
+
+///
+/// Review 07 U10: array writes. Config and the AdminUI allowed them and discovery advertised array nodes
+/// Operate-writable, but both runtimes encoded a SCALAR: an int[] threw InvalidCastException (BadTypeMismatch
+/// AND Degraded), a scalar wrote element 0 natively, and libplctag re-sent the whole last-read buffer with only
+/// element 0 patched. The driver now encodes count elements at the element width, refuses a length mismatch
+/// with BadOutOfRange , and converts element values (ScadaBridge writes int[] to INT arrays).
+///
+[Trait("Category", "Unit")]
+public sealed class AbCipArrayWriteTests
+{
+ private const string Device = "ab://10.0.0.5/1,0";
+
+ private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory)> BuildAsync(
+ AbCipDataType type, int count, int size = -1)
+ {
+ var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Size = size } };
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions(Device)],
+ RawTags = AbCipRawTags.From(new AbCipTagDefinition("Arr", Device, "Arr", type, ElementCount: count, IsArray: true)),
+ Probe = new AbCipProbeOptions { Enabled = false },
+ }, "abcip-array-write", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ return (drv, factory);
+ }
+
+ // ---- the codec ----
+
+ /// Each element is converted and laid out little-endian at the element width.
+ [Fact]
+ public void The_codec_converts_elements_and_lays_them_out_at_the_element_width()
+ {
+ AbCipArrayCodec.Encode(AbCipDataType.Int, [1, -2, 300]).ShouldBe(Convert.FromHexString("0100FEFF2C01"));
+ AbCipArrayCodec.Encode(AbCipDataType.SInt, [(short)-1, 5L]).ShouldBe(Convert.FromHexString("FF05"));
+ AbCipArrayCodec.Encode(AbCipDataType.DInt, [1.0, 2]).ShouldBe(Convert.FromHexString("0100000002000000"));
+ AbCipArrayCodec.Encode(AbCipDataType.Real, [1, 2.5]).ShouldBe(Convert.FromHexString("0000803F00002040"));
+ AbCipArrayCodec.Encode(AbCipDataType.ULInt, [ulong.MaxValue]).ShouldBe(Convert.FromHexString("FFFFFFFFFFFFFFFF"));
+ Should.Throw(() => AbCipArrayCodec.Encode(AbCipDataType.Int, [40000]));
+ Should.Throw(() => AbCipArrayCodec.Encode(AbCipDataType.Bool, [true]));
+ }
+
+ // ---- the driver ----
+
+ /// ScadaBridge's wire shape: an int[] to an INT[3] encodes 3 two-byte elements and writes once, Good.
+ [Fact]
+ public async Task An_int_array_to_an_INT_array_encodes_every_element()
+ {
+ var (drv, factory) = await BuildAsync(AbCipDataType.Int, 3, size: 6);
+
+ var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3 })], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
+ var tag = factory.Tags["Arr"];
+ tag.EncodedArrayType.ShouldBe(AbCipDataType.Int);
+ tag.EncodedArrayCount.ShouldBe(3);
+ tag.EncodedArrayBytes.ShouldBe(Convert.FromHexString("010002000300"));
+ tag.WriteCount.ShouldBe(1);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ /// A list of a different length is BadOutOfRange; nothing is created, encoded or written.
+ /// The client's element count.
+ [Theory]
+ [InlineData(2)]
+ [InlineData(4)]
+ public async Task A_length_mismatch_is_BadOutOfRange_and_nothing_is_written(int length)
+ {
+ var (drv, factory) = await BuildAsync(AbCipDataType.DInt, 3);
+
+ var r = await drv.WriteAsync([new WriteRequest("Arr", Enumerable.Range(0, length).ToArray())], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
+ factory.Tags.ShouldNotContainKey("Arr"); // refused before any runtime exists
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ /// A scalar (or a string) to an array tag is BadTypeMismatch — it used to write element 0 only.
+ [Fact]
+ public async Task A_scalar_to_an_array_tag_is_BadTypeMismatch()
+ {
+ var (drv, factory) = await BuildAsync(AbCipDataType.DInt, 3);
+
+ var r = await drv.WriteAsync(
+ [new WriteRequest("Arr", 5), new WriteRequest("Arr", "1,2,3")], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
+ r[1].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
+ factory.Tags.ShouldNotContainKey("Arr");
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ /// An element that does not fit is BadOutOfRange with no health write and nothing sent.
+ [Fact]
+ public async Task An_element_overflow_is_BadOutOfRange_and_nothing_is_sent()
+ {
+ var (drv, factory) = await BuildAsync(AbCipDataType.Int, 2, size: 4);
+
+ var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 70000 })], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
+ factory.Tags["Arr"].WriteCount.ShouldBe(0);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ /// BOOL / STRING arrays are refused BadNotSupported before any runtime exists.
+ /// The array element type.
+ [Theory]
+ [InlineData(AbCipDataType.Bool)]
+ [InlineData(AbCipDataType.String)]
+ public async Task Unencodable_element_types_are_BadNotSupported(AbCipDataType type)
+ {
+ var (drv, factory) = await BuildAsync(type, 2);
+
+ var r = await drv.WriteAsync([new WriteRequest("Arr", new object[] { true, false })], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
+ factory.Tags.ShouldNotContainKey("Arr");
+ }
+
+ /// The controller holding a different shape than authored (size ≠ width × count) is BadTypeMismatch, nothing sent.
+ [Fact]
+ public async Task A_controller_shape_mismatch_is_BadTypeMismatch_and_nothing_is_sent()
+ {
+ var (drv, factory) = await BuildAsync(AbCipDataType.Int, 3, size: 12); // DINT[3] on the controller
+
+ var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3 })], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
+ factory.Tags["Arr"].WriteCount.ShouldBe(0);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ ///
+ /// End to end over the REAL native runtime: an int[] to an INT[3] goes out as ONE Write Tag of type
+ /// 0xC3 with element count 3 and the three 2-byte elements.
+ ///
+ [Fact]
+ public async Task A_native_array_write_is_one_write_of_count_elements()
+ {
+ await using var target = new FakeCipTarget();
+ target.ConnectedHandler = req => Task.FromResult(req[0] switch
+ {
+ 0x52 => [0xD2, 0x00, 0x00, 0x00, 0xC3, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], // INT[3] = 0,0,0
+ 0x4D => [0xCD, 0x00, 0x00, 0x00],
+ _ => target.ServiceReply(req),
+ });
+ var host = $"ab://127.0.0.1:{target.Port}/1,0";
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions(host, AbCipPlcFamily.ControlLogix)],
+ RawTags = AbCipRawTags.From(new AbCipTagDefinition("Arr", host, "Arr", AbCipDataType.Int, ElementCount: 3, IsArray: true)),
+ Probe = new AbCipProbeOptions { Enabled = false },
+ Timeout = TimeSpan.FromSeconds(5),
+ }, "abcip-array-native", new NativeAbCipTagFactory(new DeviceSessionPool()));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ try
+ {
+ var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, -2, 300 })], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
+ var write = target.ConnectedCips.Single(c => c[0] == 0x4D);
+ var typeAt = 2 + write[1] * 2;
+ write.AsSpan(typeAt).ToArray().ShouldBe(Convert.FromHexString("C300" + "0300" + "0100FEFF2C01"));
+ }
+ finally
+ {
+ await drv.DisposeAsync();
+ }
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs
index 740e0985..a95b8822 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs
@@ -115,6 +115,25 @@ internal class FakeAbCipTag : IAbCipTagRuntime
/// The value to encode.
public virtual void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => Value = value;
+ /// The element type of the last , if any.
+ public AbCipDataType? EncodedArrayType { get; private set; }
+
+ /// The bytes the last produced (the shared codec, so conversions throw as in production).
+ public byte[]? EncodedArrayBytes { get; private set; }
+
+ /// The element count of the last .
+ public int EncodedArrayCount { get; private set; }
+
+ /// Encodes an array write through the production codec and records it.
+ /// The element type.
+ /// The element values.
+ public virtual void EncodeArray(AbCipDataType type, IReadOnlyList values)
+ {
+ EncodedArrayBytes = AbCipArrayCodec.Encode(type, values);
+ EncodedArrayType = type;
+ EncodedArrayCount = values.Count;
+ }
+
/// Marks the tag as disposed.
public virtual void Dispose() => Disposed = true;
}