using System.Buffers.Binary; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; 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.AbCip.Tests; /// /// Task 13: the native template reader over , serving the real /// controller captures (rev 34.11) as Read Template reply data. The native buffer must decode /// identically to the captured libplctag buffer. /// [Trait("Category", "Unit")] public sealed class NativeAbCipTemplateReaderTests { private static readonly AbCipTagCreateParams Params = new( Gateway: "10.0.0.5", Port: 44818, CipPath: "1,0", LibplctagPlcAttribute: "controllogix", TagName: "@udt", Timeout: TimeSpan.FromSeconds(2)); /// Builds a channel serving (header + raw) the way a controller would. private static FakeCipChannel ServeTemplate(byte[] captured, int maxRequestBytes, int? statusOverrideAtFirst = null) { var ch = new FakeCipChannel(maxRequestBytes); ch.RespondPerService = ServeTemplatePerService(captured, maxRequestBytes); return ch; } private static Func ServeTemplatePerService(byte[] captured, int maxRequestBytes) { var id = BinaryPrimitives.ReadUInt16LittleEndian(captured); var defWords = BinaryPrimitives.ReadUInt32LittleEndian(captured.AsSpan(2)); var instBytes = BinaryPrimitives.ReadUInt32LittleEndian(captured.AsSpan(6)); var members = BinaryPrimitives.ReadUInt16LittleEndian(captured.AsSpan(10)); var handle = BinaryPrimitives.ReadUInt16LittleEndian(captured.AsSpan(12)); var raw = captured.AsSpan(14).ToArray(); var total = (int)(defWords * 4 - 23); return req => { if (req[0] == 0x03) { var d = new byte[30]; var w = d.AsSpan(); BinaryPrimitives.WriteUInt16LittleEndian(w, 4); BinaryPrimitives.WriteUInt16LittleEndian(w[2..], 4); BinaryPrimitives.WriteUInt16LittleEndian(w[4..], 0); BinaryPrimitives.WriteUInt32LittleEndian(w[6..], defWords); BinaryPrimitives.WriteUInt16LittleEndian(w[10..], 5); BinaryPrimitives.WriteUInt16LittleEndian(w[12..], 0); BinaryPrimitives.WriteUInt32LittleEndian(w[14..], instBytes); BinaryPrimitives.WriteUInt16LittleEndian(w[18..], 2); BinaryPrimitives.WriteUInt16LittleEndian(w[20..], 0); BinaryPrimitives.WriteUInt16LittleEndian(w[22..], members); BinaryPrimitives.WriteUInt16LittleEndian(w[24..], 1); BinaryPrimitives.WriteUInt16LittleEndian(w[26..], 0); BinaryPrimitives.WriteUInt16LittleEndian(w[28..], handle); return FakeCipChannel.Reply(0x03, 0, d); } req[0].ShouldBe((byte)0x4C); var offset = (int)BinaryPrimitives.ReadUInt32LittleEndian(req.AsSpan(8)); var count = BinaryPrimitives.ReadUInt16LittleEndian(req.AsSpan(12)); count.ShouldBeLessThanOrEqualTo((ushort)(maxRequestBytes - 16)); var n = Math.Min(count, total - offset); var done = offset + n >= total; return FakeCipChannel.Reply(0x4C, done ? (byte)0x00 : (byte)0x06, raw[offset..(offset + n)]); }; } private static async Task ReadNativeAsync(FakeCipChannel ch, ushort id) { await using var sched = new RequestScheduler(ch, allowPacking: false); var released = 0; using var reader = new NativeAbCipTemplateReader(_ => (sched, ch, () => { released++; return ValueTask.CompletedTask; })); var buf = await reader.ReadAsync(Params, id, TestContext.Current.CancellationToken); released.ShouldBe(1); return buf; } [Theory] [InlineData(CipTemplateObjectWireCaptureTests.StringTemplate, 504)] [InlineData(CipTemplateObjectWireCaptureTests.StringTemplate, 40)] [InlineData(CipTemplateObjectWireCaptureTests.LookupMemberTemplate, 504)] [InlineData(CipTemplateObjectWireCaptureTests.LookupMemberTemplate, 40)] public async Task Native_buffer_decodes_identically_to_the_captured_libplctag_buffer(string hex, int maxRequest) { var captured = Convert.FromHexString(hex); var ch = ServeTemplate(captured, maxRequest); var id = BinaryPrimitives.ReadUInt16LittleEndian(captured); var native = await ReadNativeAsync(ch, id); var expected = CipTemplateObjectDecoder.Decode(captured); var actual = CipTemplateObjectDecoder.Decode(native); expected.ShouldNotBeNull(); actual.ShouldNotBeNull(); actual.TypeName.ShouldBe(expected.TypeName); actual.TotalSize.ShouldBe(expected.TotalSize); actual.Members.Count.ShouldBe(expected.Members.Count); for (var i = 0; i < expected.Members.Count; i++) { actual.Members[i].Name.ShouldBe(expected.Members[i].Name); actual.Members[i].DataType.ShouldBe(expected.Members[i].DataType); actual.Members[i].Offset.ShouldBe(expected.Members[i].Offset); actual.Members[i].ArrayLength.ShouldBe(expected.Members[i].ArrayLength); } // The 14-byte header is byte for byte the libplctag one. native.AsSpan(0, 14).SequenceEqual(captured.AsSpan(0, 14)).ShouldBeTrue(); } [Fact] public async Task A_small_request_limit_forces_several_chunks_each_within_the_limit() { var captured = Convert.FromHexString(CipTemplateObjectWireCaptureTests.LookupMemberTemplate); var ch = ServeTemplate(captured, 40); await ReadNativeAsync(ch, 3386); ch.Sent.Count(r => r[0] == 0x4C).ShouldBeGreaterThan(2); } [Fact] public async Task A_failing_read_template_status_throws_and_still_releases_the_lease() { var captured = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate); var ch = ServeTemplate(captured, 504); var inner = ch.RespondPerService; ch.RespondPerService = req => req[0] == 0x4C ? FakeCipChannel.Reply(0x4C, 0x05, []) : inner(req); await using var sched = new RequestScheduler(ch, allowPacking: false); var released = 0; using var reader = new NativeAbCipTemplateReader(_ => (sched, ch, () => { released++; return ValueTask.CompletedTask; })); await Should.ThrowAsync(() => reader.ReadAsync(Params, 0x0FCE, TestContext.Current.CancellationToken)); released.ShouldBe(1); } [Fact] public async Task A_read_template_queued_behind_a_small_read_on_a_packing_scheduler_goes_out_alone() { var captured = Convert.FromHexString(CipTemplateObjectWireCaptureTests.LookupMemberTemplate); var ch = ServeTemplate(captured, 504); await using var sched = new RequestScheduler(ch, allowPacking: true); var ct = TestContext.Current.CancellationToken; var small = new byte[] { 0x0E, 0x02, 0x20, 0x01, 0x24, 0x01 }; // Get Attribute Single var hold = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var inner = ch.RespondPerService; var firstSmall = true; Task? s1 = null; ch.Respond = async req => { // The Get Attributes reply is delivered with a small read already queued behind it, so the // pump takes that read next (S1) and holds it in flight while the reader submits its first // Read Template: that template is genuinely queued behind another request. if (req[0] == 0x03) { s1 = sched.SendAsync(small, ct); return await ch.DefaultRespond(req); } if (req[0] == 0x0E && firstSmall) { firstSmall = false; await hold.Task; } return await ch.DefaultRespond(req); }; ch.RespondPerService = r => r[0] == 0x0E ? FakeCipChannel.OkReply(r) : inner(r); using var reader = new NativeAbCipTemplateReader(_ => (sched, ch, () => ValueTask.CompletedTask)); var read = reader.ReadAsync(Params, 3386, ct); await ch.WaitForSendsAsync(2); // Get Attributes, then S1 held in flight var s2 = sched.SendAsync(small, ct); using var bound = CancellationTokenSource.CreateLinkedTokenSource(ct); bound.CancelAfter(TimeSpan.FromSeconds(10)); while (sched.QueuedCount < 2) await Task.Delay(1, bound.Token); // S2 + the first Read Template hold.SetResult(); await read; await Task.WhenAll(s1!, s2); ch.Sent.Where(r => r[0] == 0x0A).ShouldAllBe(msp => FakeCipChannel.SplitMsp(msp).All(e => e[0] != 0x4C)); ch.Sent.ShouldContain(r => r[0] == 0x4C); // the Read Template requests went out bare } [Fact] public async Task A_template_size_over_the_bound_is_rejected() { var captured = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate); BinaryPrimitives.WriteUInt32LittleEndian(captured.AsSpan(2), 0x10000000); var ch = ServeTemplate(captured, 504); await using var sched = new RequestScheduler(ch, allowPacking: false); using var reader = new NativeAbCipTemplateReader(_ => (sched, ch, () => ValueTask.CompletedTask)); var ex = await Should.ThrowAsync( () => reader.ReadAsync(Params, 0x0FCE, TestContext.Current.CancellationToken)); ex.Status.ShouldBe(AbStatus.BadReply); ch.Sent.ShouldNotContain(r => r[0] == 0x4C); } [Fact] public async Task A_batch_reader_leases_once_for_many_reads_and_releases_on_dispose() { var captured = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate); var id = BinaryPrimitives.ReadUInt16LittleEndian(captured); var ch = ServeTemplate(captured, 504); await using var sched = new RequestScheduler(ch, allowPacking: false); int acquired = 0, released = 0; var reader = new NativeAbCipTemplateReader(_ => { acquired++; return (sched, ch, () => { released++; return ValueTask.CompletedTask; }); }, holdLeases: true); for (var i = 0; i < 5; i++) await reader.ReadAsync(Params, id, TestContext.Current.CancellationToken); acquired.ShouldBe(1); released.ShouldBe(0, "the lease is held across the batch"); reader.Dispose(); released.ShouldBe(1); reader.Dispose(); released.ShouldBe(1, "a second dispose releases nothing more"); } [Fact] public async Task A_batch_reader_leases_each_device_once() { var captured = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate); var id = BinaryPrimitives.ReadUInt16LittleEndian(captured); var ch = ServeTemplate(captured, 504); await using var sched = new RequestScheduler(ch, allowPacking: false); var acquired = new List(); using var reader = new NativeAbCipTemplateReader(p => { acquired.Add(p.Gateway); return (sched, ch, () => ValueTask.CompletedTask); }, holdLeases: true); foreach (var host in new[] { "10.0.0.5", "10.0.0.6", "10.0.0.5", "10.0.0.6" }) await reader.ReadAsync(Params with { Gateway = host }, id, TestContext.Current.CancellationToken); acquired.ShouldBe(["10.0.0.5", "10.0.0.6"]); } [Fact] public async Task The_default_reader_still_leases_per_read() { var captured = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate); var id = BinaryPrimitives.ReadUInt16LittleEndian(captured); var ch = ServeTemplate(captured, 504); await using var sched = new RequestScheduler(ch, allowPacking: false); int acquired = 0, released = 0; using var reader = new NativeAbCipTemplateReader(_ => { acquired++; return (sched, ch, () => { released++; return ValueTask.CompletedTask; }); }); for (var i = 0; i < 3; i++) await reader.ReadAsync(Params, id, TestContext.Current.CancellationToken); (acquired, released).ShouldBe((3, 3)); } [Fact] public void The_native_factory_hands_out_a_lease_holding_reader_for_a_batch() { var factory = new NativeAbCipTemplateReaderFactory(); using var batch = factory.CreateBatch(); batch.ShouldBeOfType(); // The libplctag factory keeps the default: a batch is just a reader. using var lib = ((IAbCipTemplateReaderFactory)new LibplctagTemplateReaderFactory()).CreateBatch(); lib.ShouldBeOfType(); } }