Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectWireCaptureTests.cs
T
Joseph Doherty 28bbb5e5e2 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).
2026-10-07 14:06:48 -04:00

137 lines
6.3 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Decodes a captured <c>@tags</c> Symbol Object response, byte for byte as libplctag handed
/// it back — the evidence for the entry layout, rather than another statement of it.
/// </summary>
/// <remarks>
/// <para>
/// Every other decoder test builds its own input, so the builder and the decoder can share
/// a wrong assumption and still agree. They did: both padded each entry to an even length,
/// and a controller pads nothing (Gitea #468).
/// </para>
/// <para>
/// This capture came off the AbCip integration fixture — libplctag's <c>ab_server</c>,
/// patched to serve the Symbol Object listing (see the fixture's
/// <c>Docker/patches/</c>) — and the same server's replies are read correctly by
/// libplctag's own <c>list_tags_logix</c> client. That client is the reference
/// implementation exercised against real Logix hardware, so its agreement is what makes
/// this sample evidence about controllers and not merely about our fixture.
/// </para>
/// <para>
/// Re-capture with <c>plc_tag_create("…&amp;name=@tags")</c> against the fixture and dump
/// <c>plc_tag_get_uint8</c> across <c>plc_tag_get_size</c> if the fixture's tag set changes.
/// </para>
/// </remarks>
[Trait("Category", "Unit")]
public sealed class CipSymbolObjectWireCaptureTests
{
/// <summary>
/// The nine tags the <c>controllogix</c> fixture profile serves, in wire order. 281 bytes.
/// </summary>
private const string CapturedTagsReply =
"01000000C120010001000000000000000000000009004D6F746F725F52756E" +
"02000000C42004000100000000000000000000000B004C696E65315F436F756E74" +
"03000000CA2004000100000000000000000000000C0050756D705F315F5370656564" +
"04000000C42004001000000000000000000000000900546573744172726179" +
"05000000D02058000100000000000000000000000A0054657374537472696E67" +
"06000000C220010001000000000000000000000008005465737453494E54" +
"07000000C1200100010000000000000000000000080054657374424F4F4C" +
"08000000CA2004000100000000000000000000000800546573745245414C" +
"09000000C420040001000000000000000000000008005465737444494E54";
private static byte[] Capture() => Convert.FromHexString(CapturedTagsReply);
/// <summary>The capture is the length the entry layout predicts — no padding anywhere.</summary>
/// <remarks>
/// Nine entries of 22 header bytes plus their name lengths
/// (9 + 11 + 12 + 9 + 10 + 8 + 8 + 8 + 8 = 83) come to 281. An even-padded stream would be
/// 285, so the length alone falsifies the old assumption.
/// </remarks>
[Fact]
public void The_capture_is_packed_with_no_padding_between_entries()
{
Capture().Length.ShouldBe((9 * 22) + 83);
}
/// <summary>Every tag the fixture serves comes back, in wire order.</summary>
/// <remarks>
/// The first entry's name is 9 bytes, so the second entry begins at offset 31 — an odd
/// offset. Under the old even-alignment step the decoder read <c>Motor_Run</c> and then
/// lost all eight tags behind it.
/// </remarks>
[Fact]
public void Every_captured_symbol_decodes_in_wire_order()
{
var tags = CipSymbolObjectDecoder.Decode(Capture()).ToList();
tags.Select(t => t.Name).ShouldBe([
"Motor_Run",
"Line1_Count",
"Pump_1_Speed",
"TestArray",
"TestString",
"TestSINT",
"TestBOOL",
"TestREAL",
"TestDINT",
]);
}
/// <summary>Types survive the array-dimension bits a real symbol type carries.</summary>
/// <remarks>
/// Each captured symbol type is <c>0x20xx</c>, not <c>0x00xx</c>: bits 13-14 hold the array
/// dimension count. Only the low 12 bits are the CIP type code, and reading the word whole
/// would classify every one of these as an unknown type.
/// </remarks>
[Fact]
public void Captured_symbol_types_map_through_the_dimension_bits()
{
var tags = CipSymbolObjectDecoder.Decode(Capture()).ToList();
tags.Single(t => t.Name == "Motor_Run").DataType.ShouldBe(AbCipDataType.Bool);
tags.Single(t => t.Name == "Line1_Count").DataType.ShouldBe(AbCipDataType.DInt);
tags.Single(t => t.Name == "Pump_1_Speed").DataType.ShouldBe(AbCipDataType.Real);
tags.Single(t => t.Name == "TestString").DataType.ShouldBe(AbCipDataType.String);
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
/// bit wide in the wrong place would show up as a plain DINT being reported as a UDT — and
/// a UDT is fanned out into member tags, so the error would not stay quiet.
/// </remarks>
[Fact]
public void No_captured_symbol_is_misread_as_a_system_tag_or_a_structure()
{
var tags = CipSymbolObjectDecoder.Decode(Capture()).ToList();
tags.ShouldAllBe(t => !t.IsSystemTag);
tags.ShouldAllBe(t => t.DataType != AbCipDataType.Structure);
tags.ShouldAllBe(t => t.TemplateInstanceId == null);
}
}