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
This commit is contained in:
+70
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 U10: an N-file array write (<c>N7:2</c>, 4 elements) round-trips every element against
|
||||
/// <c>ab_server</c> on both transports, and leaves the words either side untouched. Simulator-only (gated by
|
||||
/// <see cref="IntegrationWriteGate"/>); the real-controller proof is a hardware userGate.
|
||||
/// </summary>
|
||||
[Collection(AbLegacyServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Simulator", "ab_server-PCCC")]
|
||||
public sealed class AbLegacyArrayWriteIntegrationTests(AbLegacyServerFixture sim)
|
||||
{
|
||||
/// <summary>Seeds the neighbours, writes an <c>int[]</c> to a 4-element Int array, reads it all back.</summary>
|
||||
/// <param name="profile">The simulator profile.</param>
|
||||
/// <param name="transport">The transport stack the driver runs on.</param>
|
||||
[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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 U10 (user decision "implement array encode now"): an array write used to encode a SCALAR — an
|
||||
/// <c>int[]</c> threw <see cref="InvalidCastException"/> into <c>BadTypeMismatch</c> + 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 <c>count</c> elements at the element width, converting each element (ScadaBridge writes CLR lists
|
||||
/// such as <c>int[]</c> to Int16 nodes), and refuses a length mismatch with <c>BadOutOfRange</c>.
|
||||
/// </summary>
|
||||
[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<string> IntPayloads => ["int[]", "short[]", "List<int>", "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<int>" => new List<int> { 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<ArgumentException>(() => 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<byte>(req[6..13]) { 0x4F, 0x00, req[15], req[16] };
|
||||
return FakeCipChannel.Reply(0x4B, 0x00, [.. body]);
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,11 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime
|
||||
/// <param name="value">The value to encode.</param>
|
||||
public virtual void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) => Value = value;
|
||||
|
||||
/// <summary>Encodes a whole typed array (array writes); stored as <see cref="ArrayValue"/>.</summary>
|
||||
/// <param name="type">The AbLegacy element type.</param>
|
||||
/// <param name="values">The typed elements.</param>
|
||||
public virtual void EncodeArray(AbLegacyDataType type, Array values) => ArrayValue = values;
|
||||
|
||||
/// <summary>Disposes the tag.</summary>
|
||||
public virtual void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user