3b48e05e9a
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.
99 lines
4.7 KiB
C#
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/<id></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();
|
|
}
|
|
}
|