Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipTemplateObjectWireCaptureTests.cs
T
Joseph Doherty 3b48e05e9a fix(abcip): decode real @udt template buffers; browse STRINGs as String, hide BOOL hosts
Live read-only check against production CompactLogix 5069-L320ERS2 rev 34.11 presses
(Z-35162 / 10.209.70.160): every @udt/<id> template the controller returned decoded to
null, so controller-discovered UDTs never fanned out. The decoder assumed a 12-byte
header led by the member count and [info][array][offset] blocks with ';'-terminated
names; libplctag actually returns a 14-byte header (u16 id, u32 def words, u32 instance
size, u16 member count, u16 handle), [info][type][offset] blocks, and NUL-terminated
names after a 'Name;<tail>' type name. All three test builders restated the old
assumption, so the unit suite agreed with itself (the #468 trap again); they now share a
TemplateBufferBuilder pinned by real captures (CipTemplateObjectWireCaptureTests).

Discovery follow-ons that the working decoder exposed:
- STRING (and user string types, LEN DINT + DATA SINT[n]) browse as one String variable,
  top-level and nested, not as a LEN/DATA folder.
- Controller-generated members (ZZZZZZZZZZ* BOOL hosts, __* AOI backing) are not browsed.
- A UDT array is not fanned out into element-free Tag.Member paths.
2026-10-07 14:04:11 -04:00

99 lines
4.7 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Decodes <c>@udt/&lt;id&gt;</c> template buffers captured byte for byte off a real controller —
/// a CompactLogix 5069-L320ERS2 rev 34.11, read through libplctag 1.5.2's <c>Tag.GetBuffer()</c>
/// on 2026-10-07 (read-only; one template read per id).
/// </summary>
/// <remarks>
/// <para>
/// Every pre-existing template test built its own input in the layout the decoder assumed
/// (u16 member count first, a 12-byte header, <c>[info][array][offset]</c> blocks, names
/// each ending <c>;\0</c>). A real buffer agrees with none of that, so the decoder returned
/// <c>null</c> for every UDT a controller has and discovered UDTs never fanned out. The
/// same builder-shares-the-parser's-assumption trap as #468.
/// </para>
/// <para>
/// What libplctag actually hands back for <c>@udt/id</c> (cross-checked against its own
/// <c>list_tags_logix</c> example): a 14-byte header — u16 template id, u32 member-definition
/// size in 32-bit words, u32 instance size in bytes, u16 member count, u16 struct handle —
/// then one 8-byte block per member: u16 <b>info</b> (array length, or the bit number of a
/// BOOL), u16 <b>type</b> (bit 15 = struct, bit 13 = array, low 12 bits = CIP type code or
/// the nested template id), u32 byte offset. Then the strings: the type name ending at
/// <c>;</c> (followed by an encoded tail up to a NUL), then each member name NUL-terminated.
/// </para>
/// </remarks>
[Trait("Category", "Unit")]
public sealed class CipTemplateObjectWireCaptureTests
{
/// <summary>The built-in STRING template (id 0x0FCE): LEN DINT + DATA SINT[82], 88 bytes.</summary>
internal const string StringTemplate =
"CE0F10000000580000000200CE0F0000C400000000005200C22004000000" +
"535452494E473B6E45414941004C454E004441544100000001000200";
/// <summary>
/// Rockwell library UDT <c>raC_UDT_LookupMember_STR0082</c> (id 3386): DINT, a nested
/// STRING member (struct type 0x8FCE), SINT, BOOL. Trailing bytes after the last name are
/// part of the capture and must be ignored.
/// </summary>
internal const string LookupMemberTemplate =
"3A0D1F000000600000000400B3810000C400000000000000CE8F040000000000C2005C0000000000C1005C000000" +
"7261435F5544545F4C6F6F6B75704D656D6265725F535452303038323B6E45414141454245430043" +
"6F64650044657363004461746154797065004C6F636B00008F92361DA1314013";
private static AbCipUdtShape DecodeHex(string hex)
{
var shape = CipTemplateObjectDecoder.Decode(Convert.FromHexString(hex));
shape.ShouldNotBeNull();
return shape;
}
/// <summary>The STRING template decodes to its two members with the real array length.</summary>
[Fact]
public void Captured_STRING_template_decodes_LEN_and_DATA()
{
var shape = DecodeHex(StringTemplate);
shape.TypeName.ShouldBe("STRING");
shape.TotalSize.ShouldBe(88);
shape.Members.Select(m => m.Name).ShouldBe(["LEN", "DATA"]);
shape.Members[0].DataType.ShouldBe(AbCipDataType.DInt);
shape.Members[0].Offset.ShouldBe(0);
shape.Members[0].ArrayLength.ShouldBe(1);
shape.Members[1].DataType.ShouldBe(AbCipDataType.SInt);
shape.Members[1].Offset.ShouldBe(4);
shape.Members[1].ArrayLength.ShouldBe(82);
}
/// <summary>A nested struct member carries its template id; trailing bytes are ignored.</summary>
[Fact]
public void Captured_library_UDT_decodes_nested_STRING_member_and_ignores_trailing_bytes()
{
var shape = DecodeHex(LookupMemberTemplate);
shape.TypeName.ShouldBe("raC_UDT_LookupMember_STR0082");
shape.TotalSize.ShouldBe(96);
shape.Members.Select(m => m.Name).ShouldBe(["Code", "Desc", "DataType", "Lock"]);
shape.Members[0].DataType.ShouldBe(AbCipDataType.DInt);
shape.Members[1].DataType.ShouldBe(AbCipDataType.Structure);
shape.Members[1].NestedTemplateId.ShouldBe(0x0FCEu);
shape.Members[1].Offset.ShouldBe(4);
shape.Members[2].DataType.ShouldBe(AbCipDataType.SInt);
shape.Members[2].Offset.ShouldBe(92);
shape.Members[3].DataType.ShouldBe(AbCipDataType.Bool);
shape.Members.ShouldAllBe(m => m.ArrayLength == 1);
}
/// <summary>A shape is recognised as a Logix string family (LEN DINT + DATA SINT[n]).</summary>
[Fact]
public void Captured_STRING_template_is_a_string_family_shape_and_a_plain_UDT_is_not()
{
DecodeHex(StringTemplate).IsStringFamily.ShouldBeTrue();
DecodeHex(LookupMemberTemplate).IsStringFamily.ShouldBeFalse();
}
}