fix(abcip): browse program-scoped tags and surface symbol array dimensions

Live read-only check against production CompactLogix 5069-L320ERS2 presses:
- The controller @tags listing names each program (Program:UN01_Press, a system symbol)
  but none of its tags; those are listed only by Program:<name>.@tags. The enumerator read
  @tags alone, so every program-scoped tag was unbrowseable — including the PackML state
  (Program:UN01_Press.Out_eState) and all 51 alarm bits the Ignition project reads. The
  enumerator now reads each program's listing and decodes it with that program as scope;
  an unreadable program listing is skipped, not fatal.
- The Symbol Object decoder ignored the dimension bits + dim words, so every discovered
  array (DINT[32], REAL[32], STRING[50], UDT[23] on the two controllers checked) browsed as
  a scalar. IsArray/ElementCount are now populated (multi-dim flattened). A 1-D single
  element entry stays scalar: ab_server reports every scalar that way, a real controller
  sets no dimension bits on scalars.

Pinned by entries captured off the controllers (CipSymbolObjectRealControllerCaptureTests,
AbCipProgramScopeEnumerationTests).
This commit is contained in:
Joseph Doherty
2026-10-07 14:06:48 -04:00
parent 3b48e05e9a
commit 28bbb5e5e2
5 changed files with 294 additions and 18 deletions
@@ -42,6 +42,10 @@ public static class CipSymbolObjectDecoder
private const ushort SymbolTypeSystemFlag = 0x1000;
private const ushort SymbolTypeStructFlag = 0x8000;
private const ushort SymbolTypeTypeCodeMask = 0x0FFF;
private const int SymbolTypeDimsShift = 13;
private const int SymbolTypeDimsMask = 0x3;
private const string ProgramPrefix = "Program:";
/// <summary>
/// Decode the raw <c>@tags</c> blob into an enumerable sequence. Malformed entries at
@@ -49,22 +53,44 @@ public static class CipSymbolObjectDecoder
/// cleanly before the corruption.
/// </summary>
/// <param name="buffer">The raw byte buffer from the Symbol Object response.</param>
/// <param name="programScope">The program this listing belongs to when it is a
/// <c>Program:&lt;name&gt;.@tags</c> read — whose entries carry bare names — or <c>null</c>
/// for the controller-scope <c>@tags</c> listing.</param>
/// <returns>An enumerable sequence of discovered CIP tags.</returns>
public static IEnumerable<AbCipDiscoveredTag> Decode(byte[] buffer)
public static IEnumerable<AbCipDiscoveredTag> Decode(byte[] buffer, string? programScope = null)
{
ArgumentNullException.ThrowIfNull(buffer);
return DecodeImpl(buffer);
return DecodeImpl(buffer, programScope);
}
private static IEnumerable<AbCipDiscoveredTag> DecodeImpl(byte[] buffer)
/// <summary>
/// The program name when <paramref name="tag"/> is a controller-scope program entry
/// (<c>Program:UN01_Press</c>, a system symbol) — the name to read <c>Program:&lt;name&gt;.@tags</c>
/// for, since a controller lists program-scope tags only under their own program. <c>null</c>
/// for anything else.
/// </summary>
/// <param name="tag">A decoded controller-scope symbol.</param>
/// <returns>The program name, or <c>null</c>.</returns>
public static string? ProgramNameOf(AbCipDiscoveredTag tag)
{
ArgumentNullException.ThrowIfNull(tag);
if (!tag.IsSystemTag || tag.ProgramScope is not null) return null;
if (!tag.Name.StartsWith(ProgramPrefix, StringComparison.Ordinal)) return null;
var name = tag.Name[ProgramPrefix.Length..];
return name.Length == 0 || name.Contains('.') ? null : name;
}
private static IEnumerable<AbCipDiscoveredTag> DecodeImpl(byte[] buffer, string? listingScope)
{
var pos = 0;
while (pos + FixedHeaderSize <= buffer.Length)
{
var instanceId = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(pos));
var symbolType = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(pos + 4));
// element_length at pos+6 (u16) — useful for array sizing but not surfaced here
// array_dims at pos+8, pos+12, pos+16 — same (scalar-tag case has all zeros)
// element_length at pos+6 (u16) — not surfaced.
var dim0 = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(pos + 8));
var dim1 = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(pos + 12));
var dim2 = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(pos + 16));
var nameLength = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(pos + 20));
pos += FixedHeaderSize;
@@ -83,7 +109,10 @@ public static class CipSymbolObjectDecoder
var isStruct = (symbolType & SymbolTypeStructFlag) != 0;
var typeCode = symbolType & SymbolTypeTypeCodeMask;
var (programScope, simpleName) = SplitProgramScope(name);
// A program listing (Program:X.@tags) carries bare names; the scope is the program read.
var (programScope, simpleName) = listingScope is null
? SplitProgramScope(name)
: (listingScope, name);
var dataType = isStruct ? AbCipDataType.Structure : MapTypeCode((byte)typeCode);
// For a struct symbol the lower 12 bits (typeCode) are NOT a primitive CIP type code —
@@ -92,18 +121,46 @@ public static class CipSymbolObjectDecoder
// carry no template id.
var templateInstanceId = isStruct ? (uint?)typeCode : null;
var (isArray, elementCount) = ArrayShape((symbolType >> SymbolTypeDimsShift) & SymbolTypeDimsMask,
dim0, dim1, dim2);
yield return new AbCipDiscoveredTag(
Name: simpleName,
ProgramScope: programScope,
DataType: dataType ?? AbCipDataType.Structure, // unknown type code → treat as opaque
ReadOnly: false, // Symbol Object doesn't carry write-protection bits; lift via AccessControl Object later
IsSystemTag: isSystem,
ElementCount: elementCount,
IsArray: isArray,
TemplateInstanceId: templateInstanceId);
_ = instanceId; // retained in the wire format for diagnostics; not surfaced to the driver today
}
}
/// <summary>
/// Array shape from the symbol type's dimension count (bits 13-14) and the three dimension
/// words. A real controller sets no dimension bits on a scalar; <c>ab_server</c> reports every
/// scalar as a one-dimensional array of one, so a 1-D single-element entry is taken as a scalar
/// too. A multi-dimensional array is surfaced flattened (element count = product of dims) —
/// the driver addresses 1-D arrays only.
/// </summary>
/// <param name="dimCount">Dimension count from bits 13-14 of the symbol type.</param>
/// <param name="dim0">First dimension.</param>
/// <param name="dim1">Second dimension.</param>
/// <param name="dim2">Third dimension.</param>
/// <returns>Whether the symbol is an array, and its flattened element count.</returns>
internal static (bool IsArray, int ElementCount) ArrayShape(int dimCount, uint dim0, uint dim1, uint dim2)
{
if (dimCount == 0) return (false, 1);
long count = 1;
if (dimCount >= 1) count *= Math.Max(1u, dim0);
if (dimCount >= 2) count *= Math.Max(1u, dim1);
if (dimCount >= 3) count *= Math.Max(1u, dim2);
if (dimCount == 1 && count == 1) return (false, 1);
return (true, (int)Math.Min(count, int.MaxValue));
}
/// <summary>
/// Split a <c>Program:MainProgram.StepIndex</c>-style name into its scope + local
/// parts. Names without the <c>Program:</c> prefix pass through unchanged.
@@ -19,29 +19,73 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// </remarks>
internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
{
private Tag? _tag;
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)
{
_tag = BuildSymbolListTag(deviceParams);
var buffer = await _readListing(deviceParams, "@tags", cancellationToken).ConfigureAwait(false);
await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
await _tag.ReadAsync(cancellationToken).ConfigureAwait(false);
var buffer = _tag.GetBuffer();
// 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(
AbCipTagCreateParams deviceParams, string listingName, CancellationToken cancellationToken)
{
using var tag = BuildSymbolListTag(deviceParams, listingName);
await tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
await tag.ReadAsync(cancellationToken).ConfigureAwait(false);
return tag.GetBuffer();
}
/// <summary>
/// Builds the (uninitialized) libplctag tag for the <c>@tags</c> pseudo — same gateway +
/// path as the device, distinguished by the name alone.
/// Builds the (uninitialized) libplctag tag for a symbol listing pseudo-tag — <c>@tags</c> or
/// <c>Program:&lt;name&gt;.@tags</c> — same gateway + path as the device, distinguished by the
/// name alone.
/// </summary>
/// <remarks>
/// Gateway must be <see cref="AbCipTagCreateParams.LibplctagGateway"/> (host:port), never the
@@ -49,18 +93,21 @@ internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
/// host, so a browse against a device authored on another port silently returns whatever
/// answers on 44818 (Gitea #539, the discovery half of #534).
/// </remarks>
internal static Tag BuildSymbolListTag(AbCipTagCreateParams deviceParams) => new()
/// <param name="deviceParams">The device's create params.</param>
/// <param name="listingName">The listing pseudo-tag name (default <c>@tags</c>).</param>
/// <returns>The uninitialized tag.</returns>
internal static Tag BuildSymbolListTag(AbCipTagCreateParams deviceParams, string listingName = "@tags") => new()
{
Gateway = deviceParams.LibplctagGateway,
Path = deviceParams.CipPath,
PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute),
Protocol = Protocol.ab_eip,
Name = "@tags",
Name = listingName,
Timeout = deviceParams.Timeout,
};
/// <summary>Disposes the enumerator and releases the underlying libplctag tag.</summary>
public void Dispose() => _tag?.Dispose();
/// <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
{