diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipSymbolObjectDecoder.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipSymbolObjectDecoder.cs
index aad6a2b1..2bf7f5d0 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipSymbolObjectDecoder.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipSymbolObjectDecoder.cs
@@ -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:";
///
/// Decode the raw @tags blob into an enumerable sequence. Malformed entries at
@@ -49,22 +53,44 @@ public static class CipSymbolObjectDecoder
/// cleanly before the corruption.
///
/// The raw byte buffer from the Symbol Object response.
+ /// The program this listing belongs to when it is a
+ /// Program:<name>.@tags read — whose entries carry bare names — or null
+ /// for the controller-scope @tags listing.
/// An enumerable sequence of discovered CIP tags.
- public static IEnumerable Decode(byte[] buffer)
+ public static IEnumerable Decode(byte[] buffer, string? programScope = null)
{
ArgumentNullException.ThrowIfNull(buffer);
- return DecodeImpl(buffer);
+ return DecodeImpl(buffer, programScope);
}
- private static IEnumerable DecodeImpl(byte[] buffer)
+ ///
+ /// The program name when is a controller-scope program entry
+ /// (Program:UN01_Press, a system symbol) — the name to read Program:<name>.@tags
+ /// for, since a controller lists program-scope tags only under their own program. null
+ /// for anything else.
+ ///
+ /// A decoded controller-scope symbol.
+ /// The program name, or null.
+ 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 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
}
}
+ ///
+ /// 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; ab_server 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.
+ ///
+ /// Dimension count from bits 13-14 of the symbol type.
+ /// First dimension.
+ /// Second dimension.
+ /// Third dimension.
+ /// Whether the symbol is an array, and its flattened element count.
+ 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));
+ }
+
///
/// Split a Program:MainProgram.StepIndex-style name into its scope + local
/// parts. Names without the Program: prefix pass through unchanged.
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 b7ed45f9..e2858385 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs
@@ -19,29 +19,73 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
///
internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
{
- private Tag? _tag;
+ 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)
{
- _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();
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(
+ 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();
}
///
- /// Builds the (uninitialized) libplctag tag for the @tags pseudo — same gateway +
- /// path as the device, distinguished by the name alone.
+ /// Builds the (uninitialized) libplctag tag for a symbol listing pseudo-tag — @tags or
+ /// Program:<name>.@tags — same gateway + path as the device, distinguished by the
+ /// name alone.
///
///
/// Gateway must be (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).
///
- internal static Tag BuildSymbolListTag(AbCipTagCreateParams deviceParams) => new()
+ /// The device's create params.
+ /// The listing pseudo-tag name (default @tags).
+ /// The uninitialized tag.
+ 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,
};
- /// Disposes the enumerator and releases the underlying libplctag tag.
- public void Dispose() => _tag?.Dispose();
+ /// Nothing to release — each listing's libplctag tag is disposed after its read.
+ public void Dispose() { }
private static PlcType MapPlcType(string attribute) => attribute switch
{
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
new file mode 100644
index 00000000..53061245
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipProgramScopeEnumerationTests.cs
@@ -0,0 +1,69 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
+
+///
+/// A controller's @tags listing names each program (Program:UN01_Press, a system
+/// symbol) but none of its tags — those are listed only by Program:<name>.@tags.
+/// Seen live on a production press: the PackML state (Program:UN01_Press.Out_eState) and
+/// every alarm bit the Ignition project reads are program-scoped, and the browse showed none.
+///
+[Trait("Category", "Unit")]
+public sealed class AbCipProgramScopeEnumerationTests
+{
+ // Captured entries (CompactLogix 5069-L320ERS2 rev 34.11): WW_InCycle + the Program:UN01_Press entry
+ // from @tags, and Out_eState from Program:UN01_Press.@tags.
+ private const string ControllerListing =
+ "AF010000C10001000000000000000000000000000A0057575F496E4379636C65" +
+ "7900000068100000000000000000000000000000120050726F6772616D3A554E30315F5072657373";
+
+ private const string Un01Listing = "1A000000C40004000000000000000000000000000A004F75745F655374617465";
+
+ private static readonly AbCipTagCreateParams Params = new(
+ Gateway: "10.0.0.5", Port: 44818, CipPath: "1,0", LibplctagPlcAttribute: "controllogix",
+ TagName: "@tags", Timeout: TimeSpan.FromSeconds(2));
+
+ /// The enumerator reads each program's own listing and yields its tags program-scoped.
+ [Fact]
+ public async Task Program_scoped_tags_are_enumerated_from_each_program_listing()
+ {
+ var reads = new List();
+ using var enumerator = new LibplctagTagEnumerator((_, name, _) =>
+ {
+ reads.Add(name);
+ return Task.FromResult(name switch
+ {
+ "@tags" => Convert.FromHexString(ControllerListing),
+ "Program:UN01_Press.@tags" => Convert.FromHexString(Un01Listing),
+ _ => throw new InvalidOperationException(name),
+ });
+ });
+
+ var tags = new List();
+ await foreach (var t in enumerator.EnumerateAsync(Params, TestContext.Current.CancellationToken))
+ tags.Add(t);
+
+ reads.ShouldBe(["@tags", "Program:UN01_Press.@tags"]);
+ var outState = tags.Single(t => t.Name == "Out_eState");
+ outState.ProgramScope.ShouldBe("UN01_Press");
+ outState.DataType.ShouldBe(AbCipDataType.DInt);
+ tags.ShouldContain(t => t.Name == "WW_InCycle" && t.ProgramScope == null);
+ }
+
+ /// One unreadable program listing does not cost the controller tags or other programs.
+ [Fact]
+ public async Task A_failing_program_listing_is_skipped()
+ {
+ using var enumerator = new LibplctagTagEnumerator((_, name, _) => name == "@tags"
+ ? Task.FromResult(Convert.FromHexString(ControllerListing))
+ : Task.FromException(new InvalidOperationException("ErrorNotFound")));
+
+ var tags = new List();
+ await foreach (var t in enumerator.EnumerateAsync(Params, TestContext.Current.CancellationToken))
+ tags.Add(t);
+
+ tags.ShouldContain(t => t.Name == "WW_InCycle");
+ }
+}
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
new file mode 100644
index 00000000..934ddcbc
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectRealControllerCaptureTests.cs
@@ -0,0 +1,85 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
+
+///
+/// Symbol Object (@tags) entries captured byte for byte off two real CompactLogix
+/// controllers on 2026-10-07 (read-only, one listing read each): a 5069-L320ERS2 rev 34.11 press
+/// and a second CompactLogix. Unlike the ab_server capture in
+/// — which reports every scalar as a 1-element
+/// array — a real controller sets the dimension bits only on genuine arrays.
+///
+[Trait("Category", "Unit")]
+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";
+
+ // Controller scope, second controller: arrSysTime (DINT[7]), arrMessages (STRING[50]).
+ private const string ArrSysTime = "11000000C42004000700000000000000000000000A0061727253797354696D65";
+ private const string ArrMessages = "61000000CEAF58003200000000000000000000000B006172724D65737361676573";
+
+ // Program:UN01_Press.@tags: names arrive WITHOUT the Program: prefix.
+ private const string OutEState = "1A000000C40004000000000000000000000000000A004F75745F655374617465";
+ private const string DisableStates = "4C000000C42004002000000000000000000000000E005F44697361626C65537461746573";
+
+ private static List Decode(params string[] entries) =>
+ CipSymbolObjectDecoder.Decode(Convert.FromHexString(string.Concat(entries))).ToList();
+
+ /// Array dimensions in the entry surface as IsArray + ElementCount; scalars stay scalar.
+ [Fact]
+ public void Real_controller_arrays_surface_their_element_count()
+ {
+ var tags = Decode(WwInCycle, InfLookupFwkB, ArrSysTime, ArrMessages);
+
+ var inCycle = tags.Single(t => t.Name == "WW_InCycle");
+ inCycle.IsArray.ShouldBeFalse();
+ inCycle.ElementCount.ShouldBe(1);
+
+ var sysTime = tags.Single(t => t.Name == "arrSysTime");
+ sysTime.DataType.ShouldBe(AbCipDataType.DInt);
+ sysTime.IsArray.ShouldBeTrue();
+ sysTime.ElementCount.ShouldBe(7);
+
+ var lookup = tags.Single(t => t.Name == "Inf_LookupFwkB");
+ lookup.DataType.ShouldBe(AbCipDataType.Structure);
+ lookup.TemplateInstanceId.ShouldBe(3386u);
+ lookup.IsArray.ShouldBeTrue();
+ lookup.ElementCount.ShouldBe(23);
+
+ var messages = tags.Single(t => t.Name == "arrMessages");
+ messages.TemplateInstanceId.ShouldBe(0x0FCEu);
+ messages.IsArray.ShouldBeTrue();
+ messages.ElementCount.ShouldBe(50);
+ }
+
+ /// A program entry is a system tag whose program name the enumerator can walk.
+ [Fact]
+ public void Program_entry_is_system_and_names_its_program()
+ {
+ var program = Decode(ProgramUn01).ShouldHaveSingleItem();
+
+ program.IsSystemTag.ShouldBeTrue();
+ CipSymbolObjectDecoder.ProgramNameOf(program).ShouldBe("UN01_Press");
+ CipSymbolObjectDecoder.ProgramNameOf(Decode(WwInCycle).Single()).ShouldBeNull();
+ }
+
+ /// A program listing decodes with the program as each tag's scope.
+ [Fact]
+ public void Program_listing_decodes_with_program_scope()
+ {
+ var tags = CipSymbolObjectDecoder.Decode(
+ Convert.FromHexString(OutEState + DisableStates), programScope: "UN01_Press").ToList();
+
+ tags.Select(t => t.Name).ShouldBe(["Out_eState", "_DisableStates"]);
+ tags.ShouldAllBe(t => t.ProgramScope == "UN01_Press");
+ tags[0].DataType.ShouldBe(AbCipDataType.DInt);
+ tags[1].IsArray.ShouldBeTrue();
+ tags[1].ElementCount.ShouldBe(32);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectWireCaptureTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectWireCaptureTests.cs
index b8fdea96..e536f7c4 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectWireCaptureTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectWireCaptureTests.cs
@@ -100,6 +100,24 @@ public sealed class CipSymbolObjectWireCaptureTests
tags.Single(t => t.Name == "TestSINT").DataType.ShouldBe(AbCipDataType.SInt);
}
+ ///
+ /// ab_server marks every symbol as a one-dimensional array (a scalar is
+ /// [1]); only TestArray has more than one element. A 1-element, 1-D entry is
+ /// treated as a scalar, so the fixture's scalars do not browse as 1-element arrays — a
+ /// real controller sets no dimension bits on a scalar at all
+ /// ().
+ ///
+ [Fact]
+ public void Only_the_multi_element_capture_entry_is_an_array()
+ {
+ var tags = CipSymbolObjectDecoder.Decode(Capture()).ToList();
+
+ var array = tags.Single(t => t.Name == "TestArray");
+ array.IsArray.ShouldBeTrue();
+ array.ElementCount.ShouldBe(16);
+ tags.Where(t => t.Name != "TestArray").ShouldAllBe(t => !t.IsArray && t.ElementCount == 1);
+ }
+
/// None of the captured symbols is misread as a system tag or a structure.
///
/// The dimension bits sit next to the flags the decoder tests for, so a mask that is one