feat(abcip): native symbol listing and template reads feeding the unchanged decoders
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -1,79 +1,14 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using libplctag;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Real <see cref="IAbCipTagEnumerator"/> that walks a Logix controller's symbol table by
|
||||
/// reading the <c>@tags</c> pseudo-tag via libplctag + decoding the CIP Symbol Object
|
||||
/// response with <see cref="CipSymbolObjectDecoder"/>.
|
||||
/// libplctag listing reader: reads a <c>@tags</c> / <c>Program:X.@tags</c> pseudo-tag and returns
|
||||
/// its raw Symbol Object bytes for <see cref="SymbolListingEnumerator"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>libplctag's <c>Tag.GetBuffer()</c> returns the raw Symbol Object bytes when the
|
||||
/// tag name is <c>@tags</c>. The decoder walks the concatenated entries + emits
|
||||
/// <see cref="AbCipDiscoveredTag"/> records matching our driver surface.</para>
|
||||
///
|
||||
/// <para><see cref="EmptyAbCipTagEnumerator"/> remains available for tests that don't want
|
||||
/// to touch the native library; the production factory default
|
||||
/// (<see cref="LibplctagTagEnumeratorFactory"/>) wires this implementation in.</para>
|
||||
/// </remarks>
|
||||
internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
|
||||
internal static class LibplctagListingReader
|
||||
{
|
||||
private readonly Func<AbCipTagCreateParams, string, CancellationToken, Task<byte[]>> _readListing;
|
||||
|
||||
/// <summary>Production enumerator: reads each listing through libplctag.</summary>
|
||||
public LibplctagTagEnumerator() : this(ReadListingAsync) { }
|
||||
|
||||
/// <summary>Enumerator over an injected listing reader (unit-test seam).</summary>
|
||||
/// <param name="readListing">Reads one symbol listing (<c>@tags</c> or <c>Program:X.@tags</c>) and
|
||||
/// returns its raw Symbol Object bytes.</param>
|
||||
internal LibplctagTagEnumerator(Func<AbCipTagCreateParams, string, CancellationToken, Task<byte[]>> readListing)
|
||||
=> _readListing = readListing;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// A controller's <c>@tags</c> listing holds controller-scope tags plus one system entry per
|
||||
/// program (<c>Program:UN01_Press</c>); the program's own tags are listed only by
|
||||
/// <c>Program:<name>.@tags</c>. 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.
|
||||
/// </remarks>
|
||||
public async IAsyncEnumerable<AbCipDiscoveredTag> 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<string>();
|
||||
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<byte[]> ReadListingAsync(
|
||||
internal static async Task<byte[]> 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,
|
||||
};
|
||||
|
||||
/// <summary>Nothing to release — each listing's libplctag tag is disposed after its read.</summary>
|
||||
public void Dispose() { }
|
||||
|
||||
private static PlcType MapPlcType(string attribute) => attribute switch
|
||||
{
|
||||
"controllogix" => PlcType.ControlLogix,
|
||||
@@ -121,9 +53,9 @@ internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Factory for <see cref="LibplctagTagEnumerator"/>.</summary>
|
||||
/// <summary>Factory for the libplctag-backed <see cref="SymbolListingEnumerator"/>.</summary>
|
||||
internal sealed class LibplctagTagEnumeratorFactory : IAbCipTagEnumeratorFactory
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public IAbCipTagEnumerator Create() => new LibplctagTagEnumerator();
|
||||
public IAbCipTagEnumerator Create() => new SymbolListingEnumerator(LibplctagListingReader.ReadAsync);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Native <see cref="IAbCipTagEnumerator"/>: one <see cref="SymbolListingEnumerator"/> 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.
|
||||
/// </summary>
|
||||
internal sealed class NativeAbCipTagEnumerator : IAbCipTagEnumerator
|
||||
{
|
||||
private readonly Func<AbCipTagCreateParams, (RequestScheduler Scheduler, Func<ValueTask> Release)> _acquire;
|
||||
|
||||
/// <summary>Production constructor: leases from <paramref name="pool"/>.</summary>
|
||||
/// <param name="pool">The connection pool (the factory passes <see cref="DeviceSessionPool.Shared"/>).</param>
|
||||
public NativeAbCipTagEnumerator(DeviceSessionPool pool)
|
||||
: this(p =>
|
||||
{
|
||||
var lease = pool.Acquire(NativeAbCipDevice.Key(p), NativeAbCipDevice.Options(p));
|
||||
return (lease.Scheduler, lease.DisposeAsync);
|
||||
})
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam: supplies the scheduler and its release directly.</summary>
|
||||
/// <param name="acquire">Acquires the scheduler for a device, with the callback that releases it.</param>
|
||||
internal NativeAbCipTagEnumerator(
|
||||
Func<AbCipTagCreateParams, (RequestScheduler Scheduler, Func<ValueTask> Release)> acquire) =>
|
||||
_acquire = acquire;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<AbCipDiscoveredTag> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Nothing to release — each enumeration owns and releases its own lease.</summary>
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
/// <summary>Pages a symbol listing off the controller into the concatenated Symbol Object entry bytes.</summary>
|
||||
internal static class NativeListingReader
|
||||
{
|
||||
private const string TagsSuffix = ".@tags";
|
||||
private const string ProgramPrefix = "Program:";
|
||||
|
||||
/// <summary>Reads <c>@tags</c> or <c>Program:X.@tags</c> in full.</summary>
|
||||
/// <param name="scheduler">The device's shared scheduler.</param>
|
||||
/// <param name="listingName">The listing pseudo-tag name.</param>
|
||||
/// <param name="cancellationToken">Cancels the read.</param>
|
||||
/// <returns>The raw entries, byte for byte what <see cref="CipSymbolObjectDecoder"/> expects.</returns>
|
||||
public static async Task<byte[]> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Factory for <see cref="NativeAbCipTagEnumerator"/>.</summary>
|
||||
internal sealed class NativeAbCipTagEnumeratorFactory : IAbCipTagEnumeratorFactory
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public IAbCipTagEnumerator Create() => new NativeAbCipTagEnumerator(DeviceSessionPool.Shared);
|
||||
}
|
||||
|
||||
/// <summary>Bounded lease release shared by the native enumerator and template reader.</summary>
|
||||
internal static class NativeLease
|
||||
{
|
||||
/// <summary>Upper bound on waiting for the lease release (and its Forward Close).</summary>
|
||||
private static readonly TimeSpan DisposeWait = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>Releases, waiting at most 2 s; never throws.</summary>
|
||||
public static void ReleaseBounded(Func<ValueTask> release)
|
||||
{
|
||||
try { release().AsTask().Wait(DisposeWait); }
|
||||
catch (AggregateException) { /* a faulted close is the pool's to log */ }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Native <see cref="IAbCipTemplateReader"/>: Get Attribute List then chunked Read Template on the
|
||||
/// device's shared scheduler, returning the 14-byte-header buffer <see cref="CipTemplateObjectDecoder"/>
|
||||
/// parses. The lease is held for one read and released when it completes.
|
||||
/// </summary>
|
||||
internal sealed class NativeAbCipTemplateReader : IAbCipTemplateReader
|
||||
{
|
||||
/// <summary>Request bytes of a Read Template that are not data room (service, path, offset, count).</summary>
|
||||
private const int RequestOverhead = 16;
|
||||
|
||||
/// <summary>Bytes of the template definition header not counted in the Read Template length.</summary>
|
||||
private const int DefinitionHeaderBytes = 23;
|
||||
|
||||
private readonly Func<AbCipTagCreateParams, (RequestScheduler Scheduler, ICipChannel Channel, Func<ValueTask> Release)> _acquire;
|
||||
|
||||
/// <summary>Production constructor: leases from <paramref name="pool"/>.</summary>
|
||||
/// <param name="pool">The connection pool (the factory passes <see cref="DeviceSessionPool.Shared"/>).</param>
|
||||
public NativeAbCipTemplateReader(DeviceSessionPool pool)
|
||||
: this(p =>
|
||||
{
|
||||
var lease = pool.Acquire(NativeAbCipDevice.Key(p), NativeAbCipDevice.Options(p));
|
||||
return (lease.Scheduler, lease.Channel, lease.DisposeAsync);
|
||||
})
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam: supplies the scheduler, channel and release directly.</summary>
|
||||
/// <param name="acquire">Acquires the connection parts for a device.</param>
|
||||
internal NativeAbCipTemplateReader(
|
||||
Func<AbCipTagCreateParams, (RequestScheduler Scheduler, ICipChannel Channel, Func<ValueTask> Release)> acquire) =>
|
||||
_acquire = acquire;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<byte[]> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Nothing to release — each read owns and releases its own lease.</summary>
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
/// <summary>Factory for <see cref="NativeAbCipTemplateReader"/>.</summary>
|
||||
internal sealed class NativeAbCipTemplateReaderFactory : IAbCipTemplateReaderFactory
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public IAbCipTemplateReader Create() => new NativeAbCipTemplateReader(DeviceSessionPool.Shared);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-neutral <see cref="IAbCipTagEnumerator"/> that walks a Logix controller's symbol table
|
||||
/// by reading the <c>@tags</c> listing through an injected reader + decoding the CIP Symbol Object
|
||||
/// bytes with <see cref="CipSymbolObjectDecoder"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The reader returns the raw Symbol Object bytes for a listing name (libplctag's
|
||||
/// <c>Tag.GetBuffer()</c> for <c>@tags</c>, or the native paged Instance Attribute List). The decoder walks the concatenated entries + emits
|
||||
/// <see cref="AbCipDiscoveredTag"/> records matching our driver surface.</para>
|
||||
///
|
||||
/// <para><see cref="EmptyAbCipTagEnumerator"/> remains available for tests that don't want
|
||||
/// to touch the native library; the production factory default
|
||||
/// (<see cref="LibplctagTagEnumeratorFactory"/>) wires this implementation in.</para>
|
||||
/// </remarks>
|
||||
internal sealed class SymbolListingEnumerator : IAbCipTagEnumerator
|
||||
{
|
||||
private readonly Func<AbCipTagCreateParams, string, CancellationToken, Task<byte[]>> _readListing;
|
||||
|
||||
/// <summary>Enumerator over a transport-specific listing reader.</summary>
|
||||
/// <param name="readListing">Reads one symbol listing (<c>@tags</c> or <c>Program:X.@tags</c>) and
|
||||
/// returns its raw Symbol Object bytes.</param>
|
||||
public SymbolListingEnumerator(Func<AbCipTagCreateParams, string, CancellationToken, Task<byte[]>> readListing)
|
||||
=> _readListing = readListing;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// A controller's <c>@tags</c> listing holds controller-scope tags plus one system entry per
|
||||
/// program (<c>Program:UN01_Press</c>); the program's own tags are listed only by
|
||||
/// <c>Program:<name>.@tags</c>. 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.
|
||||
/// </remarks>
|
||||
public async IAsyncEnumerable<AbCipDiscoveredTag> 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<string>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Nothing to release — the reader owns any per-listing resources.</summary>
|
||||
public void Dispose() { }
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
+2
-2
@@ -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")));
|
||||
|
||||
|
||||
+7
-7
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user