feat(abcip): native symbol listing and template reads feeding the unchanged decoders

This commit is contained in:
Joseph Doherty
2026-10-07 17:01:40 -04:00
parent 5dd1b987b9
commit 78e60a5a75
10 changed files with 570 additions and 85 deletions
@@ -51,7 +51,7 @@ public sealed class AbCipGatewayCompositionTests
[Fact]
public void The_symbol_list_browse_tag_carries_the_authored_port()
{
using var tag = LibplctagTagEnumerator.BuildSymbolListTag(Params("10.100.0.35", 44999));
using var tag = LibplctagListingReader.BuildSymbolListTag(Params("10.100.0.35", 44999));
tag.Gateway.ShouldBe("10.100.0.35:44999");
tag.Name.ShouldBe("@tags");
}
@@ -30,7 +30,7 @@ public sealed class AbCipProgramScopeEnumerationTests
public async Task Program_scoped_tags_are_enumerated_from_each_program_listing()
{
var reads = new List<string>();
using var enumerator = new LibplctagTagEnumerator((_, name, _) =>
using var enumerator = new SymbolListingEnumerator((_, name, _) =>
{
reads.Add(name);
return Task.FromResult(name switch
@@ -56,7 +56,7 @@ public sealed class AbCipProgramScopeEnumerationTests
[Fact]
public async Task A_failing_program_listing_is_skipped()
{
using var enumerator = new LibplctagTagEnumerator((_, name, _) => name == "@tags"
using var enumerator = new SymbolListingEnumerator((_, name, _) => name == "@tags"
? Task.FromResult(Convert.FromHexString(ControllerListing))
: Task.FromException<byte[]>(new InvalidOperationException("ErrorNotFound")));
@@ -16,17 +16,17 @@ public sealed class CipSymbolObjectRealControllerCaptureTests
{
// Controller scope, press: WW_InCycle (BOOL), Inf_LookupFwkB (UDT[23], template 3386),
// Program:UN01_Press (the program entry itself, type 0x1068).
private const string WwInCycle = "AF010000C10001000000000000000000000000000A0057575F496E4379636C65";
private const string InfLookupFwkB = "6D0000003AAD60001700000000000000000000000E00496E665F4C6F6F6B757046776B42";
private const string ProgramUn01 = "7900000068100000000000000000000000000000120050726F6772616D3A554E30315F5072657373";
internal const string WwInCycle = "AF010000C10001000000000000000000000000000A0057575F496E4379636C65";
internal const string InfLookupFwkB = "6D0000003AAD60001700000000000000000000000E00496E665F4C6F6F6B757046776B42";
internal const string ProgramUn01 = "7900000068100000000000000000000000000000120050726F6772616D3A554E30315F5072657373";
// Controller scope, second controller: arrSysTime (DINT[7]), arrMessages (STRING[50]).
private const string ArrSysTime = "11000000C42004000700000000000000000000000A0061727253797354696D65";
private const string ArrMessages = "61000000CEAF58003200000000000000000000000B006172724D65737361676573";
internal const string ArrSysTime = "11000000C42004000700000000000000000000000A0061727253797354696D65";
internal const string ArrMessages = "61000000CEAF58003200000000000000000000000B006172724D65737361676573";
// Program:UN01_Press.@tags: names arrive WITHOUT the Program: prefix.
private const string OutEState = "1A000000C40004000000000000000000000000000A004F75745F655374617465";
private const string DisableStates = "4C000000C42004002000000000000000000000000E005F44697361626C65537461746573";
internal const string OutEState = "1A000000C40004000000000000000000000000000A004F75745F655374617465";
internal const string DisableStates = "4C000000C42004002000000000000000000000000E005F44697361626C65537461746573";
private static List<AbCipDiscoveredTag> Decode(params string[] entries) =>
CipSymbolObjectDecoder.Decode(Convert.FromHexString(string.Concat(entries))).ToList();
@@ -0,0 +1,138 @@
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;
/// <summary>
/// Task 13: the native symbol enumerator over <see cref="FakeCipChannel"/>, serving the real
/// controller listing entries (rev 34.11) across pages. The decoded tag list must equal decoding
/// the captured libplctag buffer directly.
/// </summary>
[Trait("Category", "Unit")]
public sealed class NativeAbCipTagEnumeratorTests
{
private const string ControllerEntries =
CipSymbolObjectRealControllerCaptureTests.WwInCycle + CipSymbolObjectRealControllerCaptureTests.ProgramUn01 +
CipSymbolObjectRealControllerCaptureTests.InfLookupFwkB + CipSymbolObjectRealControllerCaptureTests.ArrSysTime +
CipSymbolObjectRealControllerCaptureTests.ArrMessages;
private static readonly AbCipTagCreateParams Params = new(
Gateway: "10.0.0.5", Port: 44818, CipPath: "1,0", LibplctagPlcAttribute: "controllogix",
TagName: "@tags", Timeout: TimeSpan.FromSeconds(2));
/// <summary>Splits concatenated entries into one-entry byte arrays.</summary>
private static List<byte[]> Entries(string hex)
{
var b = Convert.FromHexString(hex);
var list = new List<byte[]>();
for (var p = 0; p < b.Length;)
{
var nameLen = BinaryPrimitives.ReadUInt16LittleEndian(b.AsSpan(p + 20));
var len = 22 + nameLen;
list.Add(b[p..(p + len)]);
p += len;
}
// A controller lists instances in ascending order; paging continues from last + 1.
return [.. list.OrderBy(e => BinaryPrimitives.ReadUInt32LittleEndian(e))];
}
private static byte[] Concat(List<byte[]> entries) => [.. entries.SelectMany(e => e)];
/// <summary>Answers 0x55 listing requests from <paramref name="listings"/> (keyed by program or null), two entries a page.</summary>
private static FakeCipChannel Serve(Dictionary<string, List<byte[]>> listings, int perPage = 2)
{
var ch = new FakeCipChannel();
ch.RespondPerService = req =>
{
req[0].ShouldBe((byte)0x55);
// path: [0x91 len name pad]? 20 6B 25|26 00 instance
var pathBytes = req[1] * 2;
var path = req.AsSpan(2, pathBytes);
var key = "";
if (path[0] == 0x91)
{
var n = path[1];
key = System.Text.Encoding.ASCII.GetString(path.Slice(2, n))["Program:".Length..];
path = path[(2 + n + (n % 2))..];
}
uint start = path[2] == 0x25
? BinaryPrimitives.ReadUInt16LittleEndian(path[4..])
: BinaryPrimitives.ReadUInt32LittleEndian(path[4..]);
if (!listings.TryGetValue(key, out var all)) return FakeCipChannel.Reply(0x55, 0x05, []);
var page = all.Where(e => BinaryPrimitives.ReadUInt32LittleEndian(e) >= start).Take(perPage).ToList();
var more = all.Any(e => BinaryPrimitives.ReadUInt32LittleEndian(e) >
BinaryPrimitives.ReadUInt32LittleEndian(page[^1]));
return FakeCipChannel.Reply(0x55, more ? (byte)0x06 : (byte)0x00, [.. page.SelectMany(e => e)]);
};
return ch;
}
private static async Task<List<AbCipDiscoveredTag>> EnumerateAsync(FakeCipChannel ch, Action onRelease)
{
await using var sched = new RequestScheduler(ch, allowPacking: false);
using var enumerator = new NativeAbCipTagEnumerator(_ => (sched, () => { onRelease(); return ValueTask.CompletedTask; }));
var tags = new List<AbCipDiscoveredTag>();
await foreach (var t in enumerator.EnumerateAsync(Params, TestContext.Current.CancellationToken))
tags.Add(t);
return tags;
}
[Fact]
public async Task Paged_controller_and_program_listings_decode_identically_to_the_captured_buffers()
{
var ch = Serve(new()
{
[""] = Entries(ControllerEntries),
["UN01_Press"] = Entries(CipSymbolObjectRealControllerCaptureTests.OutEState),
});
var released = 0;
var native = await EnumerateAsync(ch, () => released++);
var expected = new List<AbCipDiscoveredTag>();
expected.AddRange(CipSymbolObjectDecoder.Decode(Concat(Entries(ControllerEntries))));
expected.AddRange(CipSymbolObjectDecoder.Decode(
Convert.FromHexString(CipSymbolObjectRealControllerCaptureTests.OutEState), programScope: "UN01_Press"));
native.Count.ShouldBe(expected.Count);
for (var i = 0; i < expected.Count; i++)
{
native[i].Name.ShouldBe(expected[i].Name);
native[i].DataType.ShouldBe(expected[i].DataType);
native[i].IsArray.ShouldBe(expected[i].IsArray);
native[i].ElementCount.ShouldBe(expected[i].ElementCount);
native[i].TemplateInstanceId.ShouldBe(expected[i].TemplateInstanceId);
native[i].ProgramScope.ShouldBe(expected[i].ProgramScope);
}
native.ShouldContain(t => t.Name == "Out_eState" && t.ProgramScope == "UN01_Press");
// Five controller entries at two a page = 3 pages; one program page. Pages continue at last + 1.
ch.Sent.Count.ShouldBe(4);
released.ShouldBe(1);
}
[Fact]
public async Task A_failing_program_listing_is_skipped_but_the_lease_is_still_released()
{
var ch = Serve(new() { [""] = Entries(ControllerEntries) }); // program listing answers 0x05
var released = 0;
var native = await EnumerateAsync(ch, () => released++);
native.Select(t => t.Name).ShouldContain("WW_InCycle");
released.ShouldBe(1);
}
[Fact]
public async Task The_reader_pages_from_last_instance_plus_one_and_returns_the_concatenated_bytes()
{
var ch = Serve(new() { [""] = Entries(ControllerEntries) });
await using var sched = new RequestScheduler(ch, allowPacking: false);
var bytes = await NativeListingReader.ReadAsync(sched, "@tags", TestContext.Current.CancellationToken);
bytes.ShouldBe(Concat(Entries(ControllerEntries)));
}
}
@@ -0,0 +1,131 @@
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;
/// <summary>
/// Task 13: the native template reader over <see cref="FakeCipChannel"/>, serving the real
/// controller captures (rev 34.11) as Read Template reply data. The native buffer must decode
/// identically to the captured libplctag buffer.
/// </summary>
[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));
/// <summary>Builds a channel serving <paramref name="captured"/> (header + raw) the way a controller would.</summary>
private static FakeCipChannel ServeTemplate(byte[] captured, int maxRequestBytes, int? statusOverrideAtFirst = null)
{
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);
var ch = new FakeCipChannel(maxRequestBytes);
ch.RespondPerService = 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)]);
};
return ch;
}
private static async Task<byte[]> 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<AbTransportException>(() =>
reader.ReadAsync(Params, 0x0FCE, TestContext.Current.CancellationToken));
released.ShouldBe(1);
}
}