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

This commit is contained in:
Joseph Doherty
2026-10-07 17:01:40 -04:00
parent 5dd1b987b9
commit 78e60a5a75
10 changed files with 570 additions and 85 deletions
@@ -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:&lt;name&gt;.@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:&lt;name&gt;.@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() { }
}