Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectDecoderTests.cs
Joseph Doherty 088c4817fe AB CIP @tags walker — CIP Symbol Object decoder + LibplctagTagEnumerator. Closes task #178. CipSymbolObjectDecoder (pure-managed, no libplctag dep) parses the raw Symbol Object (class 0x6B) blob returned by reading the @tags pseudo-tag into an enumerable sequence of AbCipDiscoveredTag records. Entry layout per Rockwell CIP Vol 1 + Logix 5000 CIP Programming Manual 1756-PM019, cross-checked against libplctag's ab/cip.c handle_listed_tags_reply — u32 instance-id + u16 symbol-type + u16 element-length + 3×u32 array-dims + u16 name-length + name[len] + even-pad. Symbol-type lower 12 bits carry the CIP type code (0xC1 BOOL, 0xC2 SINT, …, 0xD0 STRING), bit 12 is the system-tag flag, bit 15 is the struct flag (when set lower 12 bits become the template instance id). Truncated tails stop decoding gracefully — caller keeps whatever parsed cleanly rather than getting an exception mid-walk. Program:-scope names (Program:MainProgram.StepIndex) are split via SplitProgramScope so the enumerator surfaces scope + simple name separately. 12 atomic type codes mapped (BOOL/SINT/INT/DINT/LINT/USINT/UINT/UDINT/ULINT/REAL/LREAL/STRING + DT/DATE_AND_TIME under Dt); unknown codes return null so the caller treats them as opaque Structure. LibplctagTagEnumerator is the real production walker — creates a libplctag Tag with name=@tags against the device's gateway/port/path, InitializeAsync + ReadAsync + GetBuffer, hands bytes to the decoder. Factory LibplctagTagEnumeratorFactory replaces EmptyAbCipTagEnumeratorFactory as the AbCipDriver default. AbCipDriverOptions gains EnableControllerBrowse (default false) matching the TwinCAT pattern — keeps the strict-config path for deployments where only declared tags should appear. When true, DiscoverAsync walks each device's @tags + emits surviving symbols under Discovered/ sub-folder. System-tag filter (AbCipSystemTagFilter shipped in PR 5) runs alongside the wire-layer system-flag hint. Tests — 18 new CipSymbolObjectDecoderTests with crafted byte arrays matching the documented layout — single-entry DInt, theory across 12 atomic type codes, unknown→null, struct flag override, system flag surface, Program:-scope split, multi-entry wire-order with even-pad, truncated-buffer graceful stop, empty buffer, SplitProgramScope theory across 6 shapes. 4 pre-existing AbCipDriverDiscoveryTests that tested controller-enumeration behavior updated with EnableControllerBrowse=true so they continue exercising the walker path (behavior unchanged from their perspective). Total AbCip unit tests now 192/192 passing (+26 from the RMW merge's 166); full solution builds 0 errors; other drivers untouched. Field validation note — the decoder layout matches published Rockwell docs + libplctag C source, but actual @tags responses vary slightly by controller firmware (some ship an older entry format with u16 array dims instead of u32). Any layout drift surfaces as gibberish names in the Discovered/ folder; field testing will flag that for a decoder patch if it occurs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:13:20 -04:00

187 lines
6.6 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Buffers.Binary;
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class CipSymbolObjectDecoderTests
{
/// <summary>
/// Build one Symbol Object entry in the byte layout
/// <c>instance_id(u32) symbol_type(u16) element_length(u16) array_dims(u32×3) name_len(u16) name[len] pad</c>.
/// </summary>
private static byte[] BuildEntry(
uint instanceId,
ushort symbolType,
ushort elementLength,
(uint, uint, uint) arrayDims,
string name)
{
var nameBytes = Encoding.ASCII.GetBytes(name);
var nameLen = nameBytes.Length;
var totalLen = 22 + nameLen;
if ((totalLen & 1) != 0) totalLen++; // pad to even
var buf = new byte[totalLen];
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(0), instanceId);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(4), symbolType);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(6), elementLength);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(8), arrayDims.Item1);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(12), arrayDims.Item2);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(16), arrayDims.Item3);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(20), (ushort)nameLen);
Buffer.BlockCopy(nameBytes, 0, buf, 22, nameLen);
return buf;
}
private static byte[] Concat(params byte[][] chunks)
{
var total = chunks.Sum(c => c.Length);
var result = new byte[total];
var pos = 0;
foreach (var c in chunks)
{
Buffer.BlockCopy(c, 0, result, pos, c.Length);
pos += c.Length;
}
return result;
}
[Fact]
public void Single_DInt_entry_decodes_to_scalar_DInt_tag()
{
var bytes = BuildEntry(
instanceId: 42,
symbolType: 0xC4,
elementLength: 4,
arrayDims: (0, 0, 0),
name: "Counter");
var tags = CipSymbolObjectDecoder.Decode(bytes).ToList();
tags.Count.ShouldBe(1);
tags[0].Name.ShouldBe("Counter");
tags[0].ProgramScope.ShouldBeNull();
tags[0].DataType.ShouldBe(AbCipDataType.DInt);
tags[0].IsSystemTag.ShouldBeFalse();
}
[Theory]
[InlineData((byte)0xC1, AbCipDataType.Bool)]
[InlineData((byte)0xC2, AbCipDataType.SInt)]
[InlineData((byte)0xC3, AbCipDataType.Int)]
[InlineData((byte)0xC4, AbCipDataType.DInt)]
[InlineData((byte)0xC5, AbCipDataType.LInt)]
[InlineData((byte)0xC6, AbCipDataType.USInt)]
[InlineData((byte)0xC7, AbCipDataType.UInt)]
[InlineData((byte)0xC8, AbCipDataType.UDInt)]
[InlineData((byte)0xC9, AbCipDataType.ULInt)]
[InlineData((byte)0xCA, AbCipDataType.Real)]
[InlineData((byte)0xCB, AbCipDataType.LReal)]
[InlineData((byte)0xD0, AbCipDataType.String)]
public void Every_known_atomic_type_code_maps_to_correct_AbCipDataType(byte typeCode, AbCipDataType expected)
{
CipSymbolObjectDecoder.MapTypeCode(typeCode).ShouldBe(expected);
}
[Fact]
public void Unknown_type_code_returns_null_so_caller_treats_as_opaque()
{
CipSymbolObjectDecoder.MapTypeCode(0xFF).ShouldBeNull();
}
[Fact]
public void Struct_flag_overrides_type_code_and_yields_Structure()
{
// 0x8000 (struct) + 0x1234 (template instance id in lower 12 bits; uses 0x234)
var bytes = BuildEntry(
instanceId: 5,
symbolType: 0x8000 | 0x0234,
elementLength: 16,
arrayDims: (0, 0, 0),
name: "Motor1");
var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
tag.DataType.ShouldBe(AbCipDataType.Structure);
}
[Fact]
public void System_flag_surfaces_as_IsSystemTag_true()
{
var bytes = BuildEntry(
instanceId: 99,
symbolType: 0x1000 | 0xC4, // system flag + DINT
elementLength: 4,
arrayDims: (0, 0, 0),
name: "__Reserved_1");
var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
tag.IsSystemTag.ShouldBeTrue();
tag.DataType.ShouldBe(AbCipDataType.DInt);
}
[Fact]
public void Program_scope_name_splits_prefix_into_ProgramScope()
{
var bytes = BuildEntry(
instanceId: 1,
symbolType: 0xC4,
elementLength: 4,
arrayDims: (0, 0, 0),
name: "Program:MainProgram.StepIndex");
var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
tag.ProgramScope.ShouldBe("MainProgram");
tag.Name.ShouldBe("StepIndex");
}
[Fact]
public void Multiple_entries_decode_in_wire_order_with_even_padding()
{
// Name "Abc" is 3 bytes — triggers the even-pad branch between entries.
var bytes = Concat(
BuildEntry(1, 0xC4, 4, (0, 0, 0), "Abc"), // DINT named "Abc" (3-byte name, pads to 4)
BuildEntry(2, 0xCA, 4, (0, 0, 0), "Pi")); // REAL named "Pi"
var tags = CipSymbolObjectDecoder.Decode(bytes).ToList();
tags.Count.ShouldBe(2);
tags[0].Name.ShouldBe("Abc");
tags[0].DataType.ShouldBe(AbCipDataType.DInt);
tags[1].Name.ShouldBe("Pi");
tags[1].DataType.ShouldBe(AbCipDataType.Real);
}
[Fact]
public void Truncated_buffer_stops_decoding_gracefully()
{
var full = BuildEntry(7, 0xC4, 4, (0, 0, 0), "Counter");
// Deliberately chop off the last 5 bytes — decoder should bail cleanly, not throw.
var truncated = full.Take(full.Length - 5).ToArray();
CipSymbolObjectDecoder.Decode(truncated).ToList().Count.ShouldBeLessThan(1); // 0 — didn't parse the broken entry
}
[Fact]
public void Empty_buffer_yields_no_tags()
{
CipSymbolObjectDecoder.Decode([]).ShouldBeEmpty();
}
[Theory]
[InlineData("Counter", null, "Counter")]
[InlineData("Program:MainProgram.Step", "MainProgram", "Step")]
[InlineData("Program:MyProg.a.b.c", "MyProg", "a.b.c")]
[InlineData("Program:", null, "Program:")] // malformed — no dot
[InlineData("Program:OnlyProg", null, "Program:OnlyProg")]
[InlineData("Motor.Status.Running", null, "Motor.Status.Running")]
public void SplitProgramScope_handles_every_shape(string input, string? expectedScope, string expectedName)
{
var (scope, name) = CipSymbolObjectDecoder.SplitProgramScope(input);
scope.ShouldBe(expectedScope);
name.ShouldBe(expectedName);
}
}