fix(abcip): unblock the controller-browse gate, and fix the decoder it exposed (#468)
The AbCip controller browse could never be verified end to end: libplctag's ab_server answers CIP service 0x55 (Get Instance Attribute List) on the Symbol Object class with "unsupported", so the `@tags` walk had no backend. #468's own definition of done offered a sim that serves `@tags` as an alternative to real hardware; this is that. The fixture now applies a vendored patch teaching ab_server to serve the Symbol Object listing, with partial-transfer paging. Correctness is checked against libplctag's own `list_tags_logix` rather than against our decoder — our decoder is the thing under test, so its agreement would prove nothing. The clone is also pinned to a concrete tag: the Dockerfile called `--branch release` "pinned" while tracking a branch that moves. That immediately exposed a real defect. CipSymbolObjectDecoder advanced to an even byte boundary after each symbol name, but entries are packed with no padding — libplctag walks them by sizeof(entry) + string_len, and a captured reply confirms it. Any odd-length name therefore desynchronised every entry behind it, and odd-length names are ordinary. In the live browse below, `Motor_Run` is 9 bytes and first on the wire, so the old decoder would have shown that one tag and silently dropped the other eight. Every existing multi-entry test passed throughout, because the test builder padded exactly as the decoder did: the two agreed with each other and neither agreed with a controller. The builder no longer pads, and the layout is pinned by a captured reply (CipSymbolObjectWireCaptureTests) instead of by a second statement of the assumption. Restoring the alignment step reddens exactly the three new tests and none of the 29 others. Live gate on docker-dev, all three of #468's criteria: - populated tree — all nine seeded tags render as leaves, in wire order - refresh re-captures - leaf commit — Pump_1_Speed + TestDINT saved with their CIP tag paths The gate also surfaced two things it was not asked about: browse-commit drops the discovered data type, so a browsed REAL commits as a DINT and the driver decodes it as one (filed as #536), and Test Connect fails against this fixture regardless of the device because it probes `@raw_cpu_type` (documented). TwinCAT and FOCAS still have no browsable backend; #468's AbCip half is closed, theirs is not. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
+118
@@ -0,0 +1,118 @@
|
||||
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("…&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>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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user