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
@@ -0,0 +1,69 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// A controller's <c>@tags</c> listing names each program (<c>Program:UN01_Press</c>, a system
/// symbol) but none of its tags — those are listed only by <c>Program:&lt;name&gt;.@tags</c>.
/// Seen live on a production press: the PackML state (<c>Program:UN01_Press.Out_eState</c>) and
/// every alarm bit the Ignition project reads are program-scoped, and the browse showed none.
/// </summary>
[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));
/// <summary>The enumerator reads each program's own listing and yields its tags program-scoped.</summary>
[Fact]
public async Task Program_scoped_tags_are_enumerated_from_each_program_listing()
{
var reads = new List<string>();
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<AbCipDiscoveredTag>();
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);
}
/// <summary>One unreadable program listing does not cost the controller tags or other programs.</summary>
[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<byte[]>(new InvalidOperationException("ErrorNotFound")));
var tags = new List<AbCipDiscoveredTag>();
await foreach (var t in enumerator.EnumerateAsync(Params, TestContext.Current.CancellationToken))
tags.Add(t);
tags.ShouldContain(t => t.Name == "WW_InCycle");
}
}
@@ -0,0 +1,85 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Symbol Object (<c>@tags</c>) 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 <c>ab_server</c> capture in
/// <see cref="CipSymbolObjectWireCaptureTests"/> — which reports every scalar as a 1-element
/// array — a real controller sets the dimension bits only on genuine arrays.
/// </summary>
[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<AbCipDiscoveredTag> Decode(params string[] entries) =>
CipSymbolObjectDecoder.Decode(Convert.FromHexString(string.Concat(entries))).ToList();
/// <summary>Array dimensions in the entry surface as IsArray + ElementCount; scalars stay scalar.</summary>
[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);
}
/// <summary>A program entry is a system tag whose program name the enumerator can walk.</summary>
[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();
}
/// <summary>A program listing decodes with the program as each tag's scope.</summary>
[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);
}
}
@@ -100,6 +100,24 @@ public sealed class CipSymbolObjectWireCaptureTests
tags.Single(t => t.Name == "TestSINT").DataType.ShouldBe(AbCipDataType.SInt);
}
/// <summary>
/// <c>ab_server</c> marks every symbol as a one-dimensional array (a scalar is
/// <c>[1]</c>); only <c>TestArray</c> 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
/// (<see cref="CipSymbolObjectRealControllerCaptureTests"/>).
/// </summary>
[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);
}
/// <summary>None of the captured symbols is misread as a system tag or a structure.</summary>
/// <remarks>
/// The dimension bits sit next to the flags the decoder tests for, so a mask that is one