diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
index 2ff21171..d0713112 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
@@ -1286,7 +1286,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
// Controller-discovered tags — opt-in via EnableControllerBrowse. The real @tags
- // walker (LibplctagTagEnumerator) is the factory default,
+ // walker (SymbolListingEnumerator) is the factory default,
// so leaving the flag off keeps the strict-config path for deployments where only
// declared tags should appear.
if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state))
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs
index f9626b00..304baae7 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs
@@ -1,79 +1,14 @@
-using System.Runtime.CompilerServices;
using libplctag;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
///
-/// Real that walks a Logix controller's symbol table by
-/// reading the @tags pseudo-tag via libplctag + decoding the CIP Symbol Object
-/// response with .
+/// libplctag listing reader: reads a @tags / Program:X.@tags pseudo-tag and returns
+/// its raw Symbol Object bytes for .
///
-///
-/// libplctag's Tag.GetBuffer() returns the raw Symbol Object bytes when the
-/// tag name is @tags. The decoder walks the concatenated entries + emits
-/// records matching our driver surface.
-///
-/// remains available for tests that don't want
-/// to touch the native library; the production factory default
-/// () wires this implementation in.
-///
-internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
+internal static class LibplctagListingReader
{
- private readonly Func> _readListing;
-
- /// Production enumerator: reads each listing through libplctag.
- public LibplctagTagEnumerator() : this(ReadListingAsync) { }
-
- /// Enumerator over an injected listing reader (unit-test seam).
- /// Reads one symbol listing (@tags or Program:X.@tags) and
- /// returns its raw Symbol Object bytes.
- internal LibplctagTagEnumerator(Func> readListing)
- => _readListing = readListing;
-
- ///
- ///
- /// A controller's @tags listing holds controller-scope tags plus one system entry per
- /// program (Program:UN01_Press); the program's own tags are listed only by
- /// Program:<name>.@tags. So the controller listing is read first, then each program's
- /// listing, decoded with that program as the scope. A program whose listing cannot be read is
- /// skipped — browse is best-effort and one unreadable program must not cost the others.
- ///
- public async IAsyncEnumerable EnumerateAsync(
- AbCipTagCreateParams deviceParams,
- [EnumeratorCancellation] CancellationToken cancellationToken)
- {
- var buffer = await _readListing(deviceParams, "@tags", cancellationToken).ConfigureAwait(false);
-
- // The decoder already surfaces each struct tag's CIP template instance id on
- // AbCipDiscoveredTag.TemplateInstanceId (from the lower 12 bits of the symbol type), so
- // re-yielding the decoded records as-is carries it through to DiscoverAsync's UDT fan-out.
- var programs = new List();
- foreach (var tag in CipSymbolObjectDecoder.Decode(buffer))
- {
- if (CipSymbolObjectDecoder.ProgramNameOf(tag) is { } program) programs.Add(program);
- yield return tag;
- }
-
- foreach (var program in programs)
- {
- byte[] programBuffer;
- try
- {
- programBuffer = await _readListing(deviceParams, $"Program:{program}.@tags", cancellationToken)
- .ConfigureAwait(false);
- }
- catch (OperationCanceledException) { throw; }
- catch (Exception)
- {
- continue;
- }
-
- foreach (var tag in CipSymbolObjectDecoder.Decode(programBuffer, programScope: program))
- yield return tag;
- }
- }
-
- private static async Task ReadListingAsync(
+ internal static async Task ReadAsync(
AbCipTagCreateParams deviceParams, string listingName, CancellationToken cancellationToken)
{
using var tag = BuildSymbolListTag(deviceParams, listingName);
@@ -109,9 +44,6 @@ internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
Timeout = deviceParams.Timeout,
};
- /// Nothing to release — each listing's libplctag tag is disposed after its read.
- public void Dispose() { }
-
private static PlcType MapPlcType(string attribute) => attribute switch
{
"controllogix" => PlcType.ControlLogix,
@@ -121,9 +53,9 @@ internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
};
}
-/// Factory for .
+/// Factory for the libplctag-backed .
internal sealed class LibplctagTagEnumeratorFactory : IAbCipTagEnumeratorFactory
{
///
- public IAbCipTagEnumerator Create() => new LibplctagTagEnumerator();
+ public IAbCipTagEnumerator Create() => new SymbolListingEnumerator(LibplctagListingReader.ReadAsync);
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagEnumerator.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagEnumerator.cs
new file mode 100644
index 00000000..d9028a74
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagEnumerator.cs
@@ -0,0 +1,114 @@
+using System.Runtime.CompilerServices;
+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.Logix;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+///
+/// Native : one whose listing
+/// reader pages the Symbol Object Get Instance Attribute List service. The whole enumeration (the
+/// controller listing plus every program listing) runs on one leased device connection, sent through
+/// the lease's shared scheduler, and the lease is released when the enumeration ends.
+///
+internal sealed class NativeAbCipTagEnumerator : IAbCipTagEnumerator
+{
+ private readonly Func Release)> _acquire;
+
+ /// Production constructor: leases from .
+ /// The connection pool (the factory passes ).
+ public NativeAbCipTagEnumerator(DeviceSessionPool pool)
+ : this(p =>
+ {
+ var lease = pool.Acquire(NativeAbCipDevice.Key(p), NativeAbCipDevice.Options(p));
+ return (lease.Scheduler, lease.DisposeAsync);
+ })
+ {
+ }
+
+ /// Test seam: supplies the scheduler and its release directly.
+ /// Acquires the scheduler for a device, with the callback that releases it.
+ internal NativeAbCipTagEnumerator(
+ Func Release)> acquire) =>
+ _acquire = acquire;
+
+ ///
+ public async IAsyncEnumerable EnumerateAsync(
+ AbCipTagCreateParams deviceParams,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var (scheduler, release) = _acquire(deviceParams);
+ try
+ {
+ using var inner = new SymbolListingEnumerator(
+ (_, name, ct) => NativeListingReader.ReadAsync(scheduler, name, ct));
+ await foreach (var tag in inner.EnumerateAsync(deviceParams, cancellationToken).ConfigureAwait(false))
+ yield return tag;
+ }
+ finally
+ {
+ NativeLease.ReleaseBounded(release);
+ }
+ }
+
+ /// Nothing to release — each enumeration owns and releases its own lease.
+ public void Dispose() { }
+}
+
+/// Pages a symbol listing off the controller into the concatenated Symbol Object entry bytes.
+internal static class NativeListingReader
+{
+ private const string TagsSuffix = ".@tags";
+ private const string ProgramPrefix = "Program:";
+
+ /// Reads @tags or Program:X.@tags in full.
+ /// The device's shared scheduler.
+ /// The listing pseudo-tag name.
+ /// Cancels the read.
+ /// The raw entries, byte for byte what expects.
+ public static async Task ReadAsync(
+ RequestScheduler scheduler, string listingName, CancellationToken cancellationToken)
+ {
+ string? program = null;
+ if (listingName != "@tags")
+ {
+ if (!listingName.StartsWith(ProgramPrefix, StringComparison.Ordinal)
+ || !listingName.EndsWith(TagsSuffix, StringComparison.Ordinal))
+ throw new ArgumentException($"Unsupported symbol listing '{listingName}'.", nameof(listingName));
+ program = listingName[ProgramPrefix.Length..^TagsSuffix.Length];
+ }
+
+ using var all = new MemoryStream();
+ uint start = 0;
+ while (true)
+ {
+ var request = SymbolObjectServices.GetInstanceAttributeList(program, start);
+ var reply = await scheduler.SendAsync(request, cancellationToken).ConfigureAwait(false);
+ var (entries, last, more) = SymbolObjectServices.ParseListingReply(reply);
+ all.Write(entries);
+ if (!more) return all.ToArray();
+ start = last + 1;
+ }
+ }
+}
+
+/// Factory for .
+internal sealed class NativeAbCipTagEnumeratorFactory : IAbCipTagEnumeratorFactory
+{
+ ///
+ public IAbCipTagEnumerator Create() => new NativeAbCipTagEnumerator(DeviceSessionPool.Shared);
+}
+
+/// Bounded lease release shared by the native enumerator and template reader.
+internal static class NativeLease
+{
+ /// Upper bound on waiting for the lease release (and its Forward Close).
+ private static readonly TimeSpan DisposeWait = TimeSpan.FromSeconds(2);
+
+ /// Releases, waiting at most 2 s; never throws.
+ public static void ReleaseBounded(Func release)
+ {
+ try { release().AsTask().Wait(DisposeWait); }
+ catch (AggregateException) { /* a faulted close is the pool's to log */ }
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTemplateReader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTemplateReader.cs
new file mode 100644
index 00000000..35847805
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTemplateReader.cs
@@ -0,0 +1,96 @@
+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.Logix;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+///
+/// Native : Get Attribute List then chunked Read Template on the
+/// device's shared scheduler, returning the 14-byte-header buffer
+/// parses. The lease is held for one read and released when it completes.
+///
+internal sealed class NativeAbCipTemplateReader : IAbCipTemplateReader
+{
+ /// Request bytes of a Read Template that are not data room (service, path, offset, count).
+ private const int RequestOverhead = 16;
+
+ /// Bytes of the template definition header not counted in the Read Template length.
+ private const int DefinitionHeaderBytes = 23;
+
+ private readonly Func Release)> _acquire;
+
+ /// Production constructor: leases from .
+ /// The connection pool (the factory passes ).
+ public NativeAbCipTemplateReader(DeviceSessionPool pool)
+ : this(p =>
+ {
+ var lease = pool.Acquire(NativeAbCipDevice.Key(p), NativeAbCipDevice.Options(p));
+ return (lease.Scheduler, lease.Channel, lease.DisposeAsync);
+ })
+ {
+ }
+
+ /// Test seam: supplies the scheduler, channel and release directly.
+ /// Acquires the connection parts for a device.
+ internal NativeAbCipTemplateReader(
+ Func Release)> acquire) =>
+ _acquire = acquire;
+
+ ///
+ public async Task ReadAsync(
+ AbCipTagCreateParams deviceParams, uint templateInstanceId, CancellationToken cancellationToken)
+ {
+ if (templateInstanceId > ushort.MaxValue)
+ throw new ArgumentOutOfRangeException(nameof(templateInstanceId), "Template ids are 16-bit.");
+ var id = (ushort)templateInstanceId;
+
+ var (scheduler, channel, release) = _acquire(deviceParams);
+ try
+ {
+ var attrReply = await scheduler.SendAsync(TemplateObjectServices.GetAttributes(id), cancellationToken)
+ .ConfigureAwait(false);
+ var attrs = TemplateObjectServices.ParseAttributes(attrReply);
+
+ // The CIP-documented Read Template length: definition words * 4 - 23.
+ var total = checked((long)attrs.DefinitionWords * 4 - DefinitionHeaderBytes);
+ if (total < 0)
+ throw new AbTransportException(AbStatus.BadReply,
+ $"Template {id} reports an impossible definition size of {attrs.DefinitionWords} words.");
+
+ using var raw = new MemoryStream();
+ long offset = 0;
+ while (offset < total)
+ {
+ var chunk = (ushort)Math.Min(total - offset, channel.MaxRequestBytes - RequestOverhead);
+ var reply = await scheduler
+ .SendAsync(TemplateObjectServices.ReadTemplate(id, (uint)offset, chunk), cancellationToken)
+ .ConfigureAwait(false);
+ if (reply.GeneralStatus is not (0x00 or 0x06))
+ throw new AbTransportException(reply.Status,
+ $"Read Template {id} failed: general 0x{reply.GeneralStatus:X2}.");
+ var more = TemplateObjectServices.ReadHasMore(reply);
+ if (more && reply.Data.IsEmpty)
+ throw new AbTransportException(AbStatus.BadReply, $"Read Template {id} reported more data but returned none.");
+ raw.Write(reply.Data.Span);
+ offset += reply.Data.Length;
+ if (!more) break;
+ }
+
+ return TemplateObjectServices.BuildDecoderBuffer(id, attrs, raw.ToArray());
+ }
+ finally
+ {
+ NativeLease.ReleaseBounded(release);
+ }
+ }
+
+ /// Nothing to release — each read owns and releases its own lease.
+ public void Dispose() { }
+}
+
+/// Factory for .
+internal sealed class NativeAbCipTemplateReaderFactory : IAbCipTemplateReaderFactory
+{
+ ///
+ public IAbCipTemplateReader Create() => new NativeAbCipTemplateReader(DeviceSessionPool.Shared);
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/SymbolListingEnumerator.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/SymbolListingEnumerator.cs
new file mode 100644
index 00000000..24041170
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/SymbolListingEnumerator.cs
@@ -0,0 +1,74 @@
+using System.Runtime.CompilerServices;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+///
+/// Transport-neutral that walks a Logix controller's symbol table
+/// by reading the @tags listing through an injected reader + decoding the CIP Symbol Object
+/// bytes with .
+///
+///
+/// The reader returns the raw Symbol Object bytes for a listing name (libplctag's
+/// Tag.GetBuffer() for @tags, or the native paged Instance Attribute List). The decoder walks the concatenated entries + emits
+/// records matching our driver surface.
+///
+/// remains available for tests that don't want
+/// to touch the native library; the production factory default
+/// () wires this implementation in.
+///
+internal sealed class SymbolListingEnumerator : IAbCipTagEnumerator
+{
+ private readonly Func> _readListing;
+
+ /// Enumerator over a transport-specific listing reader.
+ /// Reads one symbol listing (@tags or Program:X.@tags) and
+ /// returns its raw Symbol Object bytes.
+ public SymbolListingEnumerator(Func> readListing)
+ => _readListing = readListing;
+
+ ///
+ ///
+ /// A controller's @tags listing holds controller-scope tags plus one system entry per
+ /// program (Program:UN01_Press); the program's own tags are listed only by
+ /// Program:<name>.@tags. So the controller listing is read first, then each program's
+ /// listing, decoded with that program as the scope. A program whose listing cannot be read is
+ /// skipped — browse is best-effort and one unreadable program must not cost the others.
+ ///
+ public async IAsyncEnumerable EnumerateAsync(
+ AbCipTagCreateParams deviceParams,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var buffer = await _readListing(deviceParams, "@tags", cancellationToken).ConfigureAwait(false);
+
+ // The decoder already surfaces each struct tag's CIP template instance id on
+ // AbCipDiscoveredTag.TemplateInstanceId (from the lower 12 bits of the symbol type), so
+ // re-yielding the decoded records as-is carries it through to DiscoverAsync's UDT fan-out.
+ var programs = new List();
+ foreach (var tag in CipSymbolObjectDecoder.Decode(buffer))
+ {
+ if (CipSymbolObjectDecoder.ProgramNameOf(tag) is { } program) programs.Add(program);
+ yield return tag;
+ }
+
+ foreach (var program in programs)
+ {
+ byte[] programBuffer;
+ try
+ {
+ programBuffer = await _readListing(deviceParams, $"Program:{program}.@tags", cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception)
+ {
+ continue;
+ }
+
+ foreach (var tag in CipSymbolObjectDecoder.Decode(programBuffer, programScope: program))
+ yield return tag;
+ }
+ }
+
+ /// Nothing to release — the reader owns any per-listing resources.
+ public void Dispose() { }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipGatewayCompositionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipGatewayCompositionTests.cs
index 6707610f..85d1a38b 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipGatewayCompositionTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipGatewayCompositionTests.cs
@@ -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");
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipProgramScopeEnumerationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipProgramScopeEnumerationTests.cs
index 53061245..eec9219a 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipProgramScopeEnumerationTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipProgramScopeEnumerationTests.cs
@@ -30,7 +30,7 @@ public sealed class AbCipProgramScopeEnumerationTests
public async Task Program_scoped_tags_are_enumerated_from_each_program_listing()
{
var reads = new List();
- 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(new InvalidOperationException("ErrorNotFound")));
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectRealControllerCaptureTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectRealControllerCaptureTests.cs
index 934ddcbc..db8a4535 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectRealControllerCaptureTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectRealControllerCaptureTests.cs
@@ -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 Decode(params string[] entries) =>
CipSymbolObjectDecoder.Decode(Convert.FromHexString(string.Concat(entries))).ToList();
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagEnumeratorTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagEnumeratorTests.cs
new file mode 100644
index 00000000..87c802e5
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagEnumeratorTests.cs
@@ -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;
+
+///
+/// Task 13: the native symbol enumerator over , serving the real
+/// controller listing entries (rev 34.11) across pages. The decoded tag list must equal decoding
+/// the captured libplctag buffer directly.
+///
+[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));
+
+ /// Splits concatenated entries into one-entry byte arrays.
+ private static List Entries(string hex)
+ {
+ var b = Convert.FromHexString(hex);
+ var list = new List();
+ 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 entries) => [.. entries.SelectMany(e => e)];
+
+ /// Answers 0x55 listing requests from (keyed by program or null), two entries a page.
+ private static FakeCipChannel Serve(Dictionary> 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> 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();
+ 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();
+ 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)));
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTemplateReaderTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTemplateReaderTests.cs
new file mode 100644
index 00000000..e7463982
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTemplateReaderTests.cs
@@ -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;
+
+///
+/// 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 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 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);
+ }
+}