From 517837e79894fdb6cd052aa20cfe3f3a7bf33603 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:25:12 -0400 Subject: [PATCH] feat(ablegacy): encode array writes element by element Review 07 U10 (user decision: implement array encode now). Both runtimes encoded a scalar for an array tag: an int[] threw InvalidCastException into BadTypeMismatch + Degraded, a scalar wrote element 0 natively, and libplctag's SetInt32(0, ..) re-sent the stale last-read buffer for elements 1..n-1. IAbLegacyTagRuntime gains EncodeArray; both runtimes write every element at the element width (Int/AnalogInt 2 bytes, Long/Float 4). The driver converts the written value before any runtime exists: each element is converted, not exact-matched (ScadaBridge writes int[] to Int16 nodes), a length other than the tag's element count is BadOutOfRange, an overflowing element is BadOutOfRange, a non-array or non-numeric payload is BadTypeMismatch, and a Bit array (bit-packed words; an encode would clobber neighbour bits) is BadNotSupported. None of these touch health or the wire. Adds an env-gated ab_server round trip for an N-file array write. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyArrayWriteEncoder.cs | 79 ++++++++ .../AbLegacyDriver.cs | 23 ++- .../IAbLegacyTagRuntime.cs | 14 ++ .../LibplctagLegacyTagRuntime.cs | 30 +++ .../NativeAbLegacyTagRuntime.cs | 26 +++ .../AbLegacyArrayWriteIntegrationTests.cs | 70 +++++++ .../AbLegacyArrayWriteTests.cs | 183 ++++++++++++++++++ .../FakeAbLegacyTag.cs | 5 + 8 files changed, 427 insertions(+), 3 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyArrayWriteEncoder.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyArrayWriteIntegrationTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs 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; }