diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyArrayWriteEncoder.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyArrayWriteEncoder.cs
new file mode 100644
index 00000000..a28e7169
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyArrayWriteEncoder.cs
@@ -0,0 +1,79 @@
+using System.Collections;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
+
+///
+/// Converts an OPC UA array-write value into the element-typed CLR array a runtime's
+/// takes. Pure: it runs BEFORE any runtime is created, so a refused
+/// write never costs a wire round trip and never touches driver health.
+///
+///
+/// Elements are converted, not exact-matched. ScadaBridge writes CLR lists such as int[] to
+/// Int16 nodes, and an OPC UA client may send any numeric array, so each element goes through the same checked
+/// the scalar path uses. A value that does not fit is BadOutOfRange ; one that is not
+/// a number at all is BadTypeMismatch .
+/// The length must equal the tag's element count (BadOutOfRange otherwise): a shorter write
+/// would have to fill the tail from the last read buffer, which is the stale-elements defect this replaces.
+/// Bit arrays are refused (BadNotSupported ): a PCCC bit array is bit-packed into words, so
+/// encoding it rewrites whole words and would clobber neighbouring bits the PLC owns. String / Timer / Counter /
+/// Control arrays never reach here — the mapper rejects them.
+///
+internal static class AbLegacyArrayWriteEncoder
+{
+ /// Converts to a typed array of exactly elements.
+ /// The tag's element data type.
+ /// The value the client wrote.
+ /// The tag's element count.
+ /// The converted array when the result is Good.
+ /// , or the Bad status the write is answered with.
+ public static uint TryConvert(AbLegacyDataType type, object? value, int count, out Array? typed)
+ {
+ typed = null;
+ if (type is AbLegacyDataType.Bit) return AbLegacyStatusMapper.BadNotSupported;
+ if (type is not (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float))
+ return AbLegacyStatusMapper.BadNotSupported;
+ if (value is null or string || value is not IEnumerable items) return AbLegacyStatusMapper.BadTypeMismatch;
+
+ var elements = items.Cast().ToList();
+ if (elements.Count != count) return AbLegacyStatusMapper.BadOutOfRange;
+ try
+ {
+ switch (type)
+ {
+ case AbLegacyDataType.Long:
+ {
+ var a = new int[count];
+ for (var i = 0; i < count; i++) a[i] = Convert.ToInt32(Element(elements[i]));
+ typed = a;
+ break;
+ }
+ case AbLegacyDataType.Float:
+ {
+ var a = new float[count];
+ for (var i = 0; i < count; i++) a[i] = Convert.ToSingle(Element(elements[i]));
+ typed = a;
+ break;
+ }
+ default:
+ {
+ var a = new short[count];
+ for (var i = 0; i < count; i++) a[i] = Convert.ToInt16(Element(elements[i]));
+ typed = a;
+ break;
+ }
+ }
+ return AbLegacyStatusMapper.Good;
+ }
+ catch (OverflowException) { return AbLegacyStatusMapper.BadOutOfRange; }
+ catch (Exception ex) when (ex is FormatException or InvalidCastException) { return AbLegacyStatusMapper.BadTypeMismatch; }
+ }
+
+ // A string element is not a number: Convert.ToInt16("12") would PARSE it, which no OPC UA numeric array carries.
+ private static object Element(object? e) => e switch
+ {
+ null => throw new InvalidCastException("null array element"),
+ string => throw new InvalidCastException("string array element"),
+ bool => throw new InvalidCastException("bool array element"),
+ _ => e,
+ };
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
index 9adfd47c..97754d94 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
@@ -612,7 +612,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
// READ, and against a device lacking that file the read failure surfaced as a comms fault —
// BadCommunicationError, evicted handle, Degraded health — instead of the refusal.
// Ahead of the bit-RMW branch too, which would otherwise read the parent word first.
- if (parsed is not null && _tagFactory.WriteRefusal(CreateParamsFor(device, def, parsed)) is { } refusal)
+ if (_tagFactory.WriteRefusal(CreateParamsFor(device, def, parsed)) is { } refusal)
{
WarnRefusalOnce(device, def, "write", refusal);
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
@@ -627,7 +627,23 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
// sub-element combined with a /N bit index, so their BitIndex is always null. NOTE: an Input-image (I) write is sent
// to the PLC like any other write — the device drives the input image from physical inputs and
// may reject it; we surface that real PCCC status rather than pre-rejecting at the driver.
- if (def.DataType == AbLegacyDataType.Bit && parsed?.BitIndex is int bit)
+ // Array writes (review 07 U10): convert and length-check BEFORE any runtime exists, then encode every
+ // element. A bit / sub-element array has no flat encoding.
+ Array? arrayValue = null;
+ if (IsArrayTag(def))
+ {
+ var conversion = parsed.BitIndex is not null || parsed.SubElement is not null
+ ? AbLegacyStatusMapper.BadNotSupported
+ : AbLegacyArrayWriteEncoder.TryConvert(def.DataType, w.Value, EffectiveArrayLength(def), out arrayValue);
+ if (conversion != AbLegacyStatusMapper.Good)
+ {
+ if (conversion == AbLegacyStatusMapper.BadNotSupported)
+ WarnRefusalOnce(device, def, "write", $"{def.DataType} array writes are not supported.");
+ results[i] = new WriteResult(conversion);
+ continue;
+ }
+ }
+ else if (def.DataType == AbLegacyDataType.Bit && parsed.BitIndex is int bit)
{
results[i] = new WriteResult(
await WriteBitInWordAsync(device, parsed, bit, w.Value, cancellationToken).ConfigureAwait(false));
@@ -643,7 +659,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
await opLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
- runtime.EncodeValue(def.DataType, parsed?.BitIndex, w.Value);
+ if (arrayValue is not null) runtime.EncodeArray(def.DataType, arrayValue);
+ else runtime.EncodeValue(def.DataType, parsed.BitIndex, w.Value);
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
status = runtime.GetStatus();
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs
index 502f6038..e41152a1 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs
@@ -50,6 +50,20 @@ public interface IAbLegacyTagRuntime : IDisposable
/// Optional bit index for bit-level access.
/// The value to encode.
void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value);
+
+ ///
+ /// Encodes EVERY element of a multi-element tag for the next , at the element width
+ /// of . is already element-typed by the driver
+ /// (short[] for Int/AnalogInt, int[] for Long, float[] for Float) and its length must
+ /// equal the runtime's element count ( ), so no element of
+ /// the write comes from a stale read buffer. Bit and structured element arrays are not encodable.
+ ///
+ /// The PCCC element data type.
+ /// The typed elements, exactly element-count long.
+ /// The length differs from the element count, or the array's element type
+ /// does not match .
+ /// has no flat array encoding.
+ void EncodeArray(AbLegacyDataType type, Array values);
}
public interface IAbLegacyTagFactory
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs
index 751e9701..3fd98a76 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs
@@ -15,6 +15,9 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
{
private readonly Tag _tag;
+ /// The element count the tag was created with (1 for a scalar).
+ private readonly int _count;
+
/// Why a write to this tag is refused before the wire ( ), else null.
private readonly string? _writeRefusal;
@@ -32,6 +35,7 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p)
{
_tag = BuildTag(p);
+ _count = Math.Max(1, p.ElementCount);
_writeRefusal = WriteRefusal(p);
}
@@ -244,6 +248,32 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
}
}
+ ///
+ public void EncodeArray(AbLegacyDataType type, Array values)
+ {
+ ArgumentNullException.ThrowIfNull(values);
+ if (values.Length != _count)
+ throw new ArgumentException($"Array write of {values.Length} elements to a {_count}-element tag.", nameof(values));
+ // Same offsets DecodeArray reads. EVERY element is set, so the buffer libplctag sends holds nothing from the
+ // last read (a scalar Set*(0, ...) used to re-send elements 1..n-1 as they were last read).
+ switch (type, values)
+ {
+ case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt, short[] s):
+ for (var i = 0; i < s.Length; i++) _tag.SetInt16(i * 2, s[i]);
+ break;
+ case (AbLegacyDataType.Long, int[] l):
+ for (var i = 0; i < l.Length; i++) _tag.SetInt32(i * 4, l[i]);
+ break;
+ case (AbLegacyDataType.Float, float[] f):
+ for (var i = 0; i < f.Length; i++) _tag.SetFloat32(i * 4, f[i]);
+ break;
+ case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float, _):
+ throw new ArgumentException($"{values.GetType().Name} is not the element type of a {type} array.", nameof(values));
+ default:
+ throw new NotSupportedException($"AbLegacyDataType {type} has no array write encoding.");
+ }
+ }
+
/// Disposes the tag and releases its resources.
public void Dispose() => _tag.Dispose();
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs
index 9a20acc0..8c372e5d 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs
@@ -448,6 +448,32 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
}
}
+ ///
+ public void EncodeArray(AbLegacyDataType type, Array values)
+ {
+ ArgumentNullException.ThrowIfNull(values);
+ if (values.Length != _count)
+ throw new ArgumentException($"Array write of {values.Length} elements to a {_count}-element tag.", nameof(values));
+ // Same layout as DecodeArray: element i at i * elementSize. Every element is overwritten, so nothing the
+ // write sends comes from the last read.
+ switch (type, values)
+ {
+ case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt, short[] s):
+ for (var i = 0; i < s.Length; i++) BinaryPrimitives.WriteInt16LittleEndian(SliceW(i * 2, 2), s[i]);
+ break;
+ case (AbLegacyDataType.Long, int[] l):
+ for (var i = 0; i < l.Length; i++) BinaryPrimitives.WriteInt32LittleEndian(SliceW(i * 4, 4), l[i]);
+ break;
+ case (AbLegacyDataType.Float, float[] f):
+ for (var i = 0; i < f.Length; i++) BinaryPrimitives.WriteSingleLittleEndian(SliceW(i * 4, 4), f[i]);
+ break;
+ case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float, _):
+ throw new ArgumentException($"{values.GetType().Name} is not the element type of a {type} array.", nameof(values));
+ default:
+ throw new NotSupportedException($"AbLegacyDataType {type} has no array write encoding.");
+ }
+ }
+
///
/// Releases the lease on the shared device connection (the last release closes it) without waiting:
/// closing does I/O (Forward Close / UnRegisterSession) and the driver disposes under a per-tag lock.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyArrayWriteIntegrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyArrayWriteIntegrationTests.cs
new file mode 100644
index 00000000..0b479e73
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyArrayWriteIntegrationTests.cs
@@ -0,0 +1,70 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
+
+///
+/// Review 07 U10: an N-file array write (N7:2 , 4 elements) round-trips every element against
+/// ab_server on both transports, and leaves the words either side untouched. Simulator-only (gated by
+/// ); the real-controller proof is a hardware userGate.
+///
+[Collection(AbLegacyServerCollection.Name)]
+[Trait("Category", "Integration")]
+[Trait("Simulator", "ab_server-PCCC")]
+public sealed class AbLegacyArrayWriteIntegrationTests(AbLegacyServerFixture sim)
+{
+ /// Seeds the neighbours, writes an int[] to a 4-element Int array, reads it all back.
+ /// The simulator profile.
+ /// The transport stack the driver runs on.
+ [AbLegacyTheory]
+ [MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))]
+ public async Task N_file_array_write_round_trips_every_element(AbLegacyServerProfile profile, AbLegacyTransport transport)
+ {
+ if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
+ IntegrationWriteGate.SkipUnlessWritesAllowed();
+
+ var deviceUri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
+ await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(deviceUri, profile.Family)],
+ RawTags = AbLegacyRawTags.Entries(
+ new AbLegacyTagDefinition("Before", deviceUri, "N7:1", AbLegacyDataType.Int),
+ new AbLegacyTagDefinition("Arr", deviceUri, "N7:2", AbLegacyDataType.Int, ArrayLength: 4),
+ new AbLegacyTagDefinition("After", deviceUri, "N7:6", AbLegacyDataType.Int)),
+ Timeout = TimeSpan.FromSeconds(5),
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ Transport = transport,
+ }, driverInstanceId: $"ablegacy-arraywrite-{profile.Family}-{transport}");
+ var ct = TestContext.Current.CancellationToken;
+ await drv.InitializeAsync("{}", ct);
+
+ if (profile.Family == AbLegacyPlcFamily.Plc5 && transport == AbLegacyTransport.Native)
+ {
+ // Native PLC-5 writes are refused before the wire (no capture of the PLC-5 write frames exists).
+ (await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3, 4 })], ct)).Single()
+ .StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
+ return;
+ }
+
+ (await drv.WriteAsync(
+ [new WriteRequest("Before", (short)111), new WriteRequest("After", (short)666)], ct))
+ .ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good);
+
+ // ScadaBridge shape: a CLR int[] to an Int16 array node.
+ (await drv.WriteAsync([new WriteRequest("Arr", new[] { 10, -20, 30, 32767 })], ct)).Single()
+ .StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+
+ var back = await drv.ReadAsync(["Before", "Arr", "After"], ct);
+ back.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
+ back[1].Value.ShouldBe(new short[] { 10, -20, 30, 32767 });
+ Convert.ToInt32(back[0].Value).ShouldBe(111); // neighbours untouched
+ Convert.ToInt32(back[2].Value).ShouldBe(666);
+
+ // A length mismatch is refused before the wire and changes nothing.
+ (await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3 })], ct)).Single()
+ .StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
+ (await drv.ReadAsync(["Arr"], ct)).Single().Value.ShouldBe(new short[] { 10, -20, 30, 32767 });
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs
new file mode 100644
index 00000000..0ef846a0
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs
@@ -0,0 +1,183 @@
+using System.Buffers.Binary;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
+
+///
+/// Review 07 U10 (user decision "implement array encode now"): an array write used to encode a SCALAR — an
+/// int[] threw into BadTypeMismatch + Degraded, and a scalar to an
+/// array tag wrote element 0 (native) or re-sent the stale last-read buffer (libplctag). The driver now encodes
+/// exactly count elements at the element width, converting each element (ScadaBridge writes CLR lists
+/// such as int[] to Int16 nodes), and refuses a length mismatch with BadOutOfRange .
+///
+[Trait("Category", "Unit")]
+public sealed class AbLegacyArrayWriteTests
+{
+ private const string Host = "ab://10.0.0.5/1,0";
+
+ private sealed class RecordingTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
+ {
+ public Array? Encoded { get; private set; }
+ public AbLegacyDataType? EncodedType { get; private set; }
+
+ public override void EncodeArray(AbLegacyDataType type, Array values)
+ {
+ EncodedType = type;
+ Encoded = values;
+ }
+ }
+
+ private static async Task<(AbLegacyDriver Drv, FakeAbLegacyTagFactory Factory)> NewAsync(
+ AbLegacyDataType type, string address, int length)
+ {
+ var factory = new FakeAbLegacyTagFactory { Customise = p => new RecordingTag(p) };
+ var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(Host)],
+ RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("Arr", Host, address, type, ArrayLength: length)),
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ }, "drv-u10", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ return (drv, factory);
+ }
+
+ public static TheoryData IntPayloads => ["int[]", "short[]", "List", "object[]"];
+
+ private static object Payload(string kind) => kind switch
+ {
+ "int[]" => new[] { 1, -2, 3 }, // ScadaBridge: CLR int[] to an Int16 node
+ "short[]" => new short[] { 1, -2, 3 },
+ "List" => new List { 1, -2, 3 },
+ _ => new object[] { 1L, (short)-2, 3.0 },
+ };
+
+ [Theory]
+ [MemberData(nameof(IntPayloads))]
+ public async Task Int_array_write_converts_each_element_and_encodes_count_elements(string kind)
+ {
+ var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 3);
+
+ var r = await drv.WriteAsync([new WriteRequest("Arr", Payload(kind))], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+ var tag = (RecordingTag)f.Tags["N7:0"];
+ tag.CreationParams.ElementCount.ShouldBe(3);
+ tag.EncodedType.ShouldBe(AbLegacyDataType.Int);
+ tag.Encoded.ShouldBe(new short[] { 1, -2, 3 });
+ tag.WriteCount.ShouldBe(1);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ [Fact]
+ public async Task Float_and_Long_arrays_encode_at_their_element_width()
+ {
+ var (fd, ff) = await NewAsync(AbLegacyDataType.Float, "F8:0", 2);
+ (await fd.WriteAsync([new WriteRequest("Arr", new[] { 1.5, 2 })], CancellationToken.None))[0]
+ .StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+ ((RecordingTag)ff.Tags["F8:0"]).Encoded.ShouldBe(new[] { 1.5f, 2f });
+
+ var (ld, lf) = await NewAsync(AbLegacyDataType.Long, "L9:0", 2);
+ (await ld.WriteAsync([new WriteRequest("Arr", new short[] { 7, -8 })], CancellationToken.None))[0]
+ .StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+ ((RecordingTag)lf.Tags["L9:0"]).Encoded.ShouldBe(new[] { 7, -8 });
+ }
+
+ [Theory]
+ [InlineData(2)]
+ [InlineData(4)]
+ public async Task Length_mismatch_is_BadOutOfRange_and_nothing_is_sent(int supplied)
+ {
+ var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 3);
+
+ var r = await drv.WriteAsync([new WriteRequest("Arr", new int[supplied])], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
+ f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ [Fact]
+ public async Task Element_overflow_is_BadOutOfRange_and_nothing_is_sent()
+ {
+ var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 2);
+
+ var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 70000 })], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
+ f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
+ }
+
+ [Theory]
+ [InlineData(5)]
+ [InlineData("1,2")]
+ public async Task Non_array_payload_or_unconvertible_element_is_BadTypeMismatch(object payload)
+ {
+ var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 2);
+
+ var r = await drv.WriteAsync([new WriteRequest("Arr", payload)], CancellationToken.None);
+ var e = await drv.WriteAsync([new WriteRequest("Arr", new object[] { 1, "x" })], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch);
+ e[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch);
+ f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ [Fact]
+ public async Task Bit_array_write_is_refused_BadNotSupported_before_any_runtime()
+ {
+ // Bit arrays are bit-packed in words: an encode of N bits would rewrite whole words from a stale read.
+ var (drv, f) = await NewAsync(AbLegacyDataType.Bit, "B3:0", 16);
+
+ var r = await drv.WriteAsync([new WriteRequest("Arr", new bool[16])], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
+ f.Tags.ShouldBeEmpty();
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ [Fact]
+ public async Task Native_array_encode_writes_every_element_at_its_offset()
+ {
+ var channel = new FakeCipChannel();
+ await using var scheduler = new RequestScheduler(channel, allowPacking: false);
+ var rt = new NativeAbLegacyTagRuntime(
+ new AbLegacyTagCreateParams("10.0.0.1", 44818, "1,0", "slc500", "N7:0", TimeSpan.FromSeconds(2), 3),
+ scheduler, 0xF33D, 0x21504345);
+ channel.Respond = req => Task.FromResult(NativeEcho(req));
+
+ rt.EncodeArray(AbLegacyDataType.Int, new short[] { 10, -1, 300 });
+ await rt.WriteAsync(CancellationToken.None);
+
+ var data = channel.Sent.Single()[23..];
+ data.Length.ShouldBe(6);
+ BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(0)).ShouldBe((short)10);
+ BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(2)).ShouldBe((short)-1);
+ BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(4)).ShouldBe((short)300);
+ rt.Dispose();
+ }
+
+ [Fact]
+ public void Native_array_encode_refuses_a_length_other_than_the_runtime_count()
+ {
+ var channel = new FakeCipChannel();
+ var scheduler = new RequestScheduler(channel, allowPacking: false);
+ var rt = new NativeAbLegacyTagRuntime(
+ new AbLegacyTagCreateParams("10.0.0.1", 44818, "1,0", "slc500", "F8:0", TimeSpan.FromSeconds(2), 3),
+ scheduler, 0xF33D, 0x21504345);
+
+ Should.Throw(() => rt.EncodeArray(AbLegacyDataType.Float, new float[2]));
+ rt.Dispose();
+ }
+
+ // Execute PCCC reply echoing the request's requestor id and TNS with STS 0 and no data.
+ private static byte[] NativeEcho(byte[] req)
+ {
+ var body = new List(req[6..13]) { 0x4F, 0x00, req[15], req[16] };
+ return FakeCipChannel.Reply(0x4B, 0x00, [.. body]);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs
index ba0a4618..dc3a868c 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs
@@ -109,6 +109,11 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime
/// The value to encode.
public virtual void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) => Value = value;
+ /// Encodes a whole typed array (array writes); stored as .
+ /// The AbLegacy element type.
+ /// The typed elements.
+ public virtual void EncodeArray(AbLegacyDataType type, Array values) => ArrayValue = values;
+
/// Disposes the tag.
public virtual void Dispose() => Disposed = true;
}