From 78e60a5a758073f9c0ec8e9de7a8d40711033896 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 7 Oct 2026 17:01:40 -0400 Subject: [PATCH] feat(abcip): native symbol listing and template reads feeding the unchanged decoders --- .../AbCipDriver.cs | 2 +- .../LibplctagTagEnumerator.cs | 80 +--------- .../NativeAbCipTagEnumerator.cs | 114 +++++++++++++++ .../NativeAbCipTemplateReader.cs | 96 ++++++++++++ .../SymbolListingEnumerator.cs | 74 ++++++++++ .../AbCipGatewayCompositionTests.cs | 2 +- .../AbCipProgramScopeEnumerationTests.cs | 4 +- ...pSymbolObjectRealControllerCaptureTests.cs | 14 +- .../NativeAbCipTagEnumeratorTests.cs | 138 ++++++++++++++++++ .../NativeAbCipTemplateReaderTests.cs | 131 +++++++++++++++++ 10 files changed, 570 insertions(+), 85 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagEnumerator.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTemplateReader.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/SymbolListingEnumerator.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagEnumeratorTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTemplateReaderTests.cs 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); + } +}