Merge AbCip fixes found against real CompactLogix presses
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Array element stride. Seen live on a production CompactLogix: reading
|
||||
/// <c>Program:UN01_Press._ModeNames</c> (<c>STR0016[32]</c>, 4 elements requested) returned an
|
||||
/// 80-byte buffer with libplctag's <c>ElementSize</c> unset; the driver strode 88 bytes (built-in
|
||||
/// STRING) and the read failed <c>ErrorOutOfBounds</c>. The real values were
|
||||
/// "undefined" / "Production" / "Maintenance" / "Manual" at a 20-byte stride.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipArrayStrideTests
|
||||
{
|
||||
/// <summary>A user string type's stride comes from the buffer, not the built-in STRING size.</summary>
|
||||
[Fact]
|
||||
public void User_string_array_strides_by_buffer_size_over_count()
|
||||
{
|
||||
LibplctagTagRuntime.ResolveArrayStride(null, bufferSize: 80, count: 4, AbCipDataType.String).ShouldBe(20);
|
||||
}
|
||||
|
||||
/// <summary>The built-in STRING still strides 88.</summary>
|
||||
[Fact]
|
||||
public void Builtin_STRING_array_strides_88()
|
||||
{
|
||||
LibplctagTagRuntime.ResolveArrayStride(null, bufferSize: 3 * 88, count: 3, AbCipDataType.String).ShouldBe(88);
|
||||
}
|
||||
|
||||
/// <summary>libplctag's own element size wins when it is populated.</summary>
|
||||
[Fact]
|
||||
public void Reported_element_size_wins()
|
||||
{
|
||||
LibplctagTagRuntime.ResolveArrayStride(4, bufferSize: 80, count: 4, AbCipDataType.DInt).ShouldBe(4);
|
||||
}
|
||||
|
||||
/// <summary>An empty or uneven buffer falls back to the static per-type size.</summary>
|
||||
[Fact]
|
||||
public void Empty_or_uneven_buffer_falls_back_to_the_type_size()
|
||||
{
|
||||
LibplctagTagRuntime.ResolveArrayStride(null, bufferSize: 0, count: 4, AbCipDataType.Real).ShouldBe(4);
|
||||
LibplctagTagRuntime.ResolveArrayStride(null, bufferSize: 81, count: 4, AbCipDataType.String).ShouldBe(88);
|
||||
}
|
||||
}
|
||||
@@ -603,6 +603,110 @@ public sealed class AbCipDriverDiscoveryTests
|
||||
cache.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ---- real-controller shapes (captures from a CompactLogix 5069-L320ERS2 rev 34.11) ----
|
||||
|
||||
private static async Task<RecordingBuilder> DiscoverWithTemplatesAsync(
|
||||
Dictionary<uint, byte[]> templates, params AbCipDiscoveredTag[] tags)
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var readers = new IdKeyedTemplateReaderFactory();
|
||||
foreach (var (id, blob) in templates) readers.Templates[id] = blob;
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", enumeratorFactory: new FakeEnumeratorFactory(tags), templateReaderFactory: readers);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A controller reports every STRING tag as a struct (symbol type 0x8FCE → template 0x0FCE,
|
||||
/// LEN + DATA). Browse must present it as the one String value it is — the read path already
|
||||
/// reads it as a String — not as a folder holding a DINT and an SINT[82].
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Discovered_STRING_struct_emits_one_String_variable_not_LEN_and_DATA()
|
||||
{
|
||||
var builder = await DiscoverWithTemplatesAsync(
|
||||
new() { [0x0FCE] = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate) },
|
||||
new AbCipDiscoveredTag("WW_WorkOrderNum", null, AbCipDataType.Structure, ReadOnly: false,
|
||||
TemplateInstanceId: 0x0FCE));
|
||||
|
||||
var v = builder.Variables.ShouldHaveSingleItem();
|
||||
v.Info.FullName.ShouldBe("WW_WorkOrderNum");
|
||||
v.Info.DriverDataType.ShouldBe(DriverDataType.String);
|
||||
v.Info.IsArray.ShouldBeFalse();
|
||||
builder.Folders.ShouldNotContain(f => f.BrowseName == "WW_WorkOrderNum");
|
||||
}
|
||||
|
||||
/// <summary>A nested STRING member of a UDT is one String leaf, addressable as Parent.Member.</summary>
|
||||
[Fact]
|
||||
public async Task Nested_STRING_member_emits_a_String_leaf()
|
||||
{
|
||||
var builder = await DiscoverWithTemplatesAsync(
|
||||
new()
|
||||
{
|
||||
[3386] = Convert.FromHexString(CipTemplateObjectWireCaptureTests.LookupMemberTemplate),
|
||||
[0x0FCE] = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate),
|
||||
},
|
||||
new AbCipDiscoveredTag("Lookup", null, AbCipDataType.Structure, ReadOnly: false,
|
||||
TemplateInstanceId: 3386));
|
||||
|
||||
var byName = builder.Variables.ToDictionary(v => v.Info.FullName, v => v.Info);
|
||||
byName.Keys.ShouldBe(["Lookup.Code", "Lookup.Desc", "Lookup.DataType", "Lookup.Lock"], ignoreOrder: true);
|
||||
byName["Lookup.Desc"].DriverDataType.ShouldBe(DriverDataType.String);
|
||||
byName["Lookup.Code"].DriverDataType.ShouldBe(DriverDataType.Int32);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logix packs BOOL members into hidden host integers (<c>ZZZZZZZZZZAlarms0</c>) and AOIs carry
|
||||
/// <c>__BitHost00</c>-style backing members. Neither is user data, and a Logix name can never
|
||||
/// contain a double underscore, so both are dropped from the browse.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Hidden_BOOL_host_members_are_not_browsed()
|
||||
{
|
||||
var alarms = TemplateBufferBuilder.Build("Alarms", 4,
|
||||
("ZZZZZZZZZZAlarms0", 0xC2, 0, 0),
|
||||
("_00", 0xC1, 0, 0),
|
||||
("__BitHost00", 0xC4, 0, 0));
|
||||
|
||||
var builder = await DiscoverWithTemplatesAsync(
|
||||
new() { [3425] = alarms },
|
||||
new AbCipDiscoveredTag("Alarms", null, AbCipDataType.Structure, ReadOnly: false,
|
||||
TemplateInstanceId: 3425));
|
||||
|
||||
builder.Variables.Select(v => v.Info.FullName).ShouldBe(["Alarms._00"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An ARRAY of a UDT is not fanned out into <c>Tag.Member</c> leaves — those paths name no
|
||||
/// element and every read of them would fail. An array of STRING is a String array.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Discovered_struct_arrays_do_not_fan_out_and_STRING_arrays_are_String_arrays()
|
||||
{
|
||||
var udt = TemplateBufferBuilder.Build("Rec", 8, ("A", 0xC4, 0, 0), ("B", 0xCA, 0, 4));
|
||||
var builder = await DiscoverWithTemplatesAsync(
|
||||
new()
|
||||
{
|
||||
[0x0FCE] = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate),
|
||||
[0x55] = udt,
|
||||
},
|
||||
new AbCipDiscoveredTag("arrMessages", null, AbCipDataType.Structure, ReadOnly: false,
|
||||
ElementCount: 50, IsArray: true, TemplateInstanceId: 0x0FCE),
|
||||
new AbCipDiscoveredTag("Stored", null, AbCipDataType.Structure, ReadOnly: false,
|
||||
ElementCount: 255, IsArray: true, TemplateInstanceId: 0x55));
|
||||
|
||||
builder.Variables.ShouldNotContain(v => v.Info.FullName.StartsWith("Stored."));
|
||||
var messages = builder.Variables.Single(v => v.Info.FullName == "arrMessages").Info;
|
||||
messages.DriverDataType.ShouldBe(DriverDataType.String);
|
||||
messages.IsArray.ShouldBeTrue();
|
||||
messages.ArrayDim.ShouldBe(50u);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
/// <summary>Test implementation of IAddressSpaceBuilder that records calls.</summary>
|
||||
@@ -714,34 +818,11 @@ public sealed class AbCipDriverDiscoveryTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a minimal CIP Template Object blob <see cref="CipTemplateObjectDecoder"/> can decode
|
||||
/// into an <see cref="AbCipUdtShape"/>. Mirrors the helper in
|
||||
/// <c>AbCipFetchUdtShapeTests</c>: 12-byte header + 8-byte member blocks + semicolon/NUL
|
||||
/// terminated UDT-then-member name strings.
|
||||
/// Build a template buffer in libplctag's real <c>@udt/id</c> layout via
|
||||
/// <see cref="TemplateBufferBuilder"/>. <c>info</c> is the member's CIP type word; <c>arr</c>
|
||||
/// > 0 marks an array member.
|
||||
/// </summary>
|
||||
private static byte[] BuildSimpleTemplate(
|
||||
string name, uint instanceSize, params (string n, ushort info, ushort arr, uint off)[] members)
|
||||
{
|
||||
const int headerSize = 12;
|
||||
const int blockSize = 8;
|
||||
var strings = new MemoryStream();
|
||||
void Add(string s) { var b = Encoding.ASCII.GetBytes(s + ";\0"); strings.Write(b, 0, b.Length); }
|
||||
Add(name);
|
||||
foreach (var m in members) Add(m.n);
|
||||
var stringsArr = strings.ToArray();
|
||||
|
||||
var buf = new byte[headerSize + blockSize * members.Length + stringsArr.Length];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), (ushort)members.Length);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 0x1234);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), instanceSize);
|
||||
for (var i = 0; i < members.Length; i++)
|
||||
{
|
||||
var o = headerSize + i * blockSize;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), members[i].info);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o + 2), members[i].arr);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), members[i].off);
|
||||
}
|
||||
Buffer.BlockCopy(stringsArr, 0, buf, headerSize + blockSize * members.Length, stringsArr.Length);
|
||||
return buf;
|
||||
}
|
||||
=> TemplateBufferBuilder.Build(name, instanceSize, [.. members.Select(m => (m.n, m.info, m.arr, m.off))]);
|
||||
}
|
||||
|
||||
@@ -60,30 +60,14 @@ public sealed class AbCipFetchUdtShapeTests
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] BuildSimpleTemplate(string name, uint instanceSize, params (string n, ushort info, ushort arr, uint off)[] members)
|
||||
{
|
||||
var headerSize = 12;
|
||||
var blockSize = 8;
|
||||
var strings = new MemoryStream();
|
||||
void Add(string s) { var b = Encoding.ASCII.GetBytes(s + ";\0"); strings.Write(b, 0, b.Length); }
|
||||
Add(name);
|
||||
foreach (var m in members) Add(m.n);
|
||||
var stringsArr = strings.ToArray();
|
||||
|
||||
var buf = new byte[headerSize + blockSize * members.Length + stringsArr.Length];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), (ushort)members.Length);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 0x1234);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), instanceSize);
|
||||
for (var i = 0; i < members.Length; i++)
|
||||
{
|
||||
var o = headerSize + i * blockSize;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), members[i].info);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o + 2), members[i].arr);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), members[i].off);
|
||||
}
|
||||
Buffer.BlockCopy(stringsArr, 0, buf, headerSize + blockSize * members.Length, stringsArr.Length);
|
||||
return buf;
|
||||
}
|
||||
/// <summary>
|
||||
/// Build a template buffer in libplctag's real <c>@udt/id</c> layout via
|
||||
/// <see cref="TemplateBufferBuilder"/>. <c>info</c> is the member's CIP type word; <c>arr</c>
|
||||
/// > 0 marks an array member.
|
||||
/// </summary>
|
||||
private static byte[] BuildSimpleTemplate(
|
||||
string name, uint instanceSize, params (string n, ushort info, ushort arr, uint off)[] members)
|
||||
=> TemplateBufferBuilder.Build(name, instanceSize, [.. members.Select(m => (m.n, m.info, m.arr, m.off))]);
|
||||
|
||||
private static Task<AbCipUdtShape?> InvokeFetch(AbCipDriver drv, string deviceHostAddress, uint templateId)
|
||||
{
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A controller's <c>@tags</c> listing names each program (<c>Program:UN01_Press</c>, a system
|
||||
/// symbol) but none of its tags — those are listed only by <c>Program:<name>.@tags</c>.
|
||||
/// Seen live on a production press: the PackML state (<c>Program:UN01_Press.Out_eState</c>) and
|
||||
/// every alarm bit the Ignition project reads are program-scoped, and the browse showed none.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipProgramScopeEnumerationTests
|
||||
{
|
||||
// Captured entries (CompactLogix 5069-L320ERS2 rev 34.11): WW_InCycle + the Program:UN01_Press entry
|
||||
// from @tags, and Out_eState from Program:UN01_Press.@tags.
|
||||
private const string ControllerListing =
|
||||
"AF010000C10001000000000000000000000000000A0057575F496E4379636C65" +
|
||||
"7900000068100000000000000000000000000000120050726F6772616D3A554E30315F5072657373";
|
||||
|
||||
private const string Un01Listing = "1A000000C40004000000000000000000000000000A004F75745F655374617465";
|
||||
|
||||
private static readonly AbCipTagCreateParams Params = new(
|
||||
Gateway: "10.0.0.5", Port: 44818, CipPath: "1,0", LibplctagPlcAttribute: "controllogix",
|
||||
TagName: "@tags", Timeout: TimeSpan.FromSeconds(2));
|
||||
|
||||
/// <summary>The enumerator reads each program's own listing and yields its tags program-scoped.</summary>
|
||||
[Fact]
|
||||
public async Task Program_scoped_tags_are_enumerated_from_each_program_listing()
|
||||
{
|
||||
var reads = new List<string>();
|
||||
using var enumerator = new LibplctagTagEnumerator((_, name, _) =>
|
||||
{
|
||||
reads.Add(name);
|
||||
return Task.FromResult(name switch
|
||||
{
|
||||
"@tags" => Convert.FromHexString(ControllerListing),
|
||||
"Program:UN01_Press.@tags" => Convert.FromHexString(Un01Listing),
|
||||
_ => throw new InvalidOperationException(name),
|
||||
});
|
||||
});
|
||||
|
||||
var tags = new List<AbCipDiscoveredTag>();
|
||||
await foreach (var t in enumerator.EnumerateAsync(Params, TestContext.Current.CancellationToken))
|
||||
tags.Add(t);
|
||||
|
||||
reads.ShouldBe(["@tags", "Program:UN01_Press.@tags"]);
|
||||
var outState = tags.Single(t => t.Name == "Out_eState");
|
||||
outState.ProgramScope.ShouldBe("UN01_Press");
|
||||
outState.DataType.ShouldBe(AbCipDataType.DInt);
|
||||
tags.ShouldContain(t => t.Name == "WW_InCycle" && t.ProgramScope == null);
|
||||
}
|
||||
|
||||
/// <summary>One unreadable program listing does not cost the controller tags or other programs.</summary>
|
||||
[Fact]
|
||||
public async Task A_failing_program_listing_is_skipped()
|
||||
{
|
||||
using var enumerator = new LibplctagTagEnumerator((_, name, _) => name == "@tags"
|
||||
? Task.FromResult(Convert.FromHexString(ControllerListing))
|
||||
: Task.FromException<byte[]>(new InvalidOperationException("ErrorNotFound")));
|
||||
|
||||
var tags = new List<AbCipDiscoveredTag>();
|
||||
await foreach (var t in enumerator.EnumerateAsync(Params, TestContext.Current.CancellationToken))
|
||||
tags.Add(t);
|
||||
|
||||
tags.ShouldContain(t => t.Name == "WW_InCycle");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// libplctag hands back whatever bytes the controller sends and decodes them as the authored type
|
||||
/// — it never checks the CIP type code. Seen live on a production press: <c>WW_ActualVacuum</c> is a
|
||||
/// REAL (the L5X and the controller's symbol table agree), the legacy Kepware export types it as
|
||||
/// Short, and reading it authored as INT returned <c>Good -17346</c> (the low half of a float)
|
||||
/// where the real value was 5.37. A scalar atomic read whose buffer is not the authored type's
|
||||
/// width is now <c>BadTypeMismatch</c>. Same-width mismatches (DINT vs REAL) remain undetectable
|
||||
/// without the type code.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipReadWidthMismatchTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
|
||||
private static async Task<uint> ReadStatusAsync(AbCipDataType authored, int bufferSize, string path = "WW_ActualVacuum")
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new FakeAbCipTag(p) { Value = 1, Size = bufferSize },
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", Device, path, authored)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "abcip-1", factory);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
var r = await drv.ReadAsync(["T"], TestContext.Current.CancellationToken);
|
||||
return r[0].StatusCode;
|
||||
}
|
||||
|
||||
/// <summary>An INT read that comes back 4 bytes wide is a type mismatch, not a Good value.</summary>
|
||||
[Fact]
|
||||
public async Task INT_authored_over_a_4_byte_REAL_is_BadTypeMismatch()
|
||||
{
|
||||
(await ReadStatusAsync(AbCipDataType.Int, bufferSize: 4)).ShouldBe(AbCipStatusMapper.BadTypeMismatch);
|
||||
}
|
||||
|
||||
/// <summary>Matching widths still read Good — including the live-observed sizes per type.</summary>
|
||||
/// <param name="type">Authored type.</param>
|
||||
/// <param name="size">Buffer size the controller returned for that type (observed live).</param>
|
||||
[Theory]
|
||||
[InlineData(AbCipDataType.Int, 2)]
|
||||
[InlineData(AbCipDataType.DInt, 4)]
|
||||
[InlineData(AbCipDataType.Real, 4)]
|
||||
[InlineData(AbCipDataType.LInt, 8)]
|
||||
[InlineData(AbCipDataType.SInt, 1)]
|
||||
public async Task Matching_width_reads_Good(AbCipDataType type, int size)
|
||||
{
|
||||
(await ReadStatusAsync(type, size)).ShouldBe(AbCipStatusMapper.Good);
|
||||
}
|
||||
|
||||
/// <summary>An unknown size (a runtime that cannot report it) never blocks a read.</summary>
|
||||
[Fact]
|
||||
public async Task Unknown_size_is_not_checked()
|
||||
{
|
||||
(await ReadStatusAsync(AbCipDataType.Int, bufferSize: -1)).ShouldBe(AbCipStatusMapper.Good);
|
||||
}
|
||||
|
||||
/// <summary>A bit-in-DINT read decodes from the parent's 4-byte buffer and is not width-checked.</summary>
|
||||
[Fact]
|
||||
public async Task Bit_index_read_is_not_width_checked()
|
||||
{
|
||||
(await ReadStatusAsync(AbCipDataType.Bool, bufferSize: 4, path: "Flags.3")).ShouldBe(AbCipStatusMapper.Good);
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,41 @@ public sealed class AbCipTagPathTests
|
||||
AbCipTagPath.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Module-defined I/O tags carry colons in their base name (<c>Local:1:I</c>,
|
||||
/// <c>ABB_OmniCore:I</c>). Seen live: a CompactLogix lists <c>ABB_OmniCore:I</c> in
|
||||
/// <c>@tags</c>, libplctag reads <c>ABB_OmniCore:I.ConnectionFaulted</c> fine, and the driver
|
||||
/// refused it as a malformed path.
|
||||
/// </summary>
|
||||
/// <param name="input">A module tag path.</param>
|
||||
/// <param name="baseName">The expected base (first segment) name.</param>
|
||||
[Theory]
|
||||
[InlineData("ABB_OmniCore:I.ConnectionFaulted", "ABB_OmniCore:I")]
|
||||
[InlineData("Local:1:I.Data", "Local:1:I")]
|
||||
[InlineData("Local:2:O.Data.3", "Local:2:O")]
|
||||
[InlineData("Rack1:3:C.Ch0Config.RangeType", "Rack1:3:C")]
|
||||
public void Module_IO_tag_base_names_with_colons_parse_and_round_trip(string input, string baseName)
|
||||
{
|
||||
var parsed = AbCipTagPath.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.ProgramScope.ShouldBeNull();
|
||||
parsed.Segments[0].Name.ShouldBe(baseName);
|
||||
parsed.ToLibplctagName().ShouldBe(input);
|
||||
}
|
||||
|
||||
/// <summary>A colon is only legal in the base name, and never as a dangling or doubled separator.</summary>
|
||||
/// <param name="input">A malformed path.</param>
|
||||
[Theory]
|
||||
[InlineData("Motor.Sub:I")] // colon past the base segment
|
||||
[InlineData("Local::I.Data")] // empty colon part
|
||||
[InlineData("Local:1:.Data")] // trailing colon
|
||||
[InlineData(":Local.Data")] // leading colon
|
||||
[InlineData("Program:P.Local:1:I")] // module tags are controller-scope only
|
||||
public void Colons_outside_a_module_base_name_are_rejected(string input)
|
||||
{
|
||||
AbCipTagPath.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that identifiers with underscores are accepted.</summary>
|
||||
[Fact]
|
||||
public void Ident_with_underscore_accepted()
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using libplctag;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A tag the controller does not have is a configuration error, not a lost connection. Seen live
|
||||
/// against a production CompactLogix: with one misspelled tag first in the read list, libplctag's
|
||||
/// create threw <c>LibPlcTagException("ErrorNotFound")</c>, the driver counted that as a failed
|
||||
/// Forward-Open, opened the device's connect-backoff window, and every other not-yet-connected tag
|
||||
/// on the device — all of them real — came back <c>BadCommunicationError</c> (throttled). The bad
|
||||
/// tag itself also read <c>BadCommunicationError</c> instead of <c>BadNodeIdUnknown</c>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipUnknownTagReadTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
|
||||
private static (AbCipDriver Driver, FakeAbCipTagFactory Factory) Build(Func<string, Exception?> initFault)
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => initFault(p.TagName) is { } ex
|
||||
? new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = ex }
|
||||
: new FakeAbCipTag(p) { Value = 42 },
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = AbCipRawTags.From(
|
||||
new AbCipTagDefinition("Typo", Device, "NoSuchTag_xyz", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("Real1", Device, "WW_InCycle", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("Real2", Device, "WW_ActualPress", AbCipDataType.DInt)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
MaxConcurrentReads = 1,
|
||||
}, "abcip-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
/// <summary>The unknown tag reads BadNodeIdUnknown and its siblings still connect and read Good.</summary>
|
||||
[Fact]
|
||||
public async Task Unknown_tag_does_not_throttle_sibling_tags_and_reads_BadNodeIdUnknown()
|
||||
{
|
||||
var (drv, _) = Build(name => name == "NoSuchTag_xyz" ? new LibPlcTagException("ErrorNotFound") : null);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var r = await drv.ReadAsync(["Typo", "Real1", "Real2"], TestContext.Current.CancellationToken);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
r[1].Value.ShouldBe(42);
|
||||
r[2].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Falsification control: a genuine connection failure still opens the backoff window, so a
|
||||
/// sibling cold tag is throttled exactly as before (05/STAB-8 is unchanged).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_connection_failure_still_opens_the_backoff_window()
|
||||
{
|
||||
var (drv, factory) = Build(name => name == "NoSuchTag_xyz" ? new LibPlcTagException("ErrorTimeout") : null);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var r = await drv.ReadAsync(["Typo", "Real1"], TestContext.Current.CancellationToken);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTimeout);
|
||||
r[1].StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
||||
factory.Tags.ContainsKey("WW_InCycle").ShouldBeFalse(); // throttled before any create
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Symbol Object (<c>@tags</c>) entries captured byte for byte off two real CompactLogix
|
||||
/// controllers on 2026-10-07 (read-only, one listing read each): a 5069-L320ERS2 rev 34.11 press
|
||||
/// and a second CompactLogix. Unlike the <c>ab_server</c> capture in
|
||||
/// <see cref="CipSymbolObjectWireCaptureTests"/> — which reports every scalar as a 1-element
|
||||
/// array — a real controller sets the dimension bits only on genuine arrays.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CipSymbolObjectRealControllerCaptureTests
|
||||
{
|
||||
// Controller scope, press: WW_InCycle (BOOL), Inf_LookupFwkB (UDT[23], template 3386),
|
||||
// Program:UN01_Press (the program entry itself, type 0x1068).
|
||||
private const string WwInCycle = "AF010000C10001000000000000000000000000000A0057575F496E4379636C65";
|
||||
private const string InfLookupFwkB = "6D0000003AAD60001700000000000000000000000E00496E665F4C6F6F6B757046776B42";
|
||||
private const string ProgramUn01 = "7900000068100000000000000000000000000000120050726F6772616D3A554E30315F5072657373";
|
||||
|
||||
// Controller scope, second controller: arrSysTime (DINT[7]), arrMessages (STRING[50]).
|
||||
private const string ArrSysTime = "11000000C42004000700000000000000000000000A0061727253797354696D65";
|
||||
private const string ArrMessages = "61000000CEAF58003200000000000000000000000B006172724D65737361676573";
|
||||
|
||||
// Program:UN01_Press.@tags: names arrive WITHOUT the Program: prefix.
|
||||
private const string OutEState = "1A000000C40004000000000000000000000000000A004F75745F655374617465";
|
||||
private const string DisableStates = "4C000000C42004002000000000000000000000000E005F44697361626C65537461746573";
|
||||
|
||||
private static List<AbCipDiscoveredTag> Decode(params string[] entries) =>
|
||||
CipSymbolObjectDecoder.Decode(Convert.FromHexString(string.Concat(entries))).ToList();
|
||||
|
||||
/// <summary>Array dimensions in the entry surface as IsArray + ElementCount; scalars stay scalar.</summary>
|
||||
[Fact]
|
||||
public void Real_controller_arrays_surface_their_element_count()
|
||||
{
|
||||
var tags = Decode(WwInCycle, InfLookupFwkB, ArrSysTime, ArrMessages);
|
||||
|
||||
var inCycle = tags.Single(t => t.Name == "WW_InCycle");
|
||||
inCycle.IsArray.ShouldBeFalse();
|
||||
inCycle.ElementCount.ShouldBe(1);
|
||||
|
||||
var sysTime = tags.Single(t => t.Name == "arrSysTime");
|
||||
sysTime.DataType.ShouldBe(AbCipDataType.DInt);
|
||||
sysTime.IsArray.ShouldBeTrue();
|
||||
sysTime.ElementCount.ShouldBe(7);
|
||||
|
||||
var lookup = tags.Single(t => t.Name == "Inf_LookupFwkB");
|
||||
lookup.DataType.ShouldBe(AbCipDataType.Structure);
|
||||
lookup.TemplateInstanceId.ShouldBe(3386u);
|
||||
lookup.IsArray.ShouldBeTrue();
|
||||
lookup.ElementCount.ShouldBe(23);
|
||||
|
||||
var messages = tags.Single(t => t.Name == "arrMessages");
|
||||
messages.TemplateInstanceId.ShouldBe(0x0FCEu);
|
||||
messages.IsArray.ShouldBeTrue();
|
||||
messages.ElementCount.ShouldBe(50);
|
||||
}
|
||||
|
||||
/// <summary>A program entry is a system tag whose program name the enumerator can walk.</summary>
|
||||
[Fact]
|
||||
public void Program_entry_is_system_and_names_its_program()
|
||||
{
|
||||
var program = Decode(ProgramUn01).ShouldHaveSingleItem();
|
||||
|
||||
program.IsSystemTag.ShouldBeTrue();
|
||||
CipSymbolObjectDecoder.ProgramNameOf(program).ShouldBe("UN01_Press");
|
||||
CipSymbolObjectDecoder.ProgramNameOf(Decode(WwInCycle).Single()).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>A program listing decodes with the program as each tag's scope.</summary>
|
||||
[Fact]
|
||||
public void Program_listing_decodes_with_program_scope()
|
||||
{
|
||||
var tags = CipSymbolObjectDecoder.Decode(
|
||||
Convert.FromHexString(OutEState + DisableStates), programScope: "UN01_Press").ToList();
|
||||
|
||||
tags.Select(t => t.Name).ShouldBe(["Out_eState", "_DisableStates"]);
|
||||
tags.ShouldAllBe(t => t.ProgramScope == "UN01_Press");
|
||||
tags[0].DataType.ShouldBe(AbCipDataType.DInt);
|
||||
tags[1].IsArray.ShouldBeTrue();
|
||||
tags[1].ElementCount.ShouldBe(32);
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,24 @@ public sealed class CipSymbolObjectWireCaptureTests
|
||||
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
|
||||
|
||||
+22
-59
@@ -10,45 +10,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
public sealed class CipTemplateObjectDecoderTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Construct a Template Object blob — header + member blocks + semicolon-delimited
|
||||
/// strings (UDT name first, then member names).
|
||||
/// Construct a template buffer in libplctag's real layout (see <see cref="TemplateBufferBuilder"/>).
|
||||
/// <c>info</c> here is the member's CIP type word; <c>arraySize</c> > 0 marks an array member.
|
||||
/// </summary>
|
||||
private static byte[] BuildTemplate(
|
||||
string udtName,
|
||||
uint instanceSize,
|
||||
params (string name, ushort info, ushort arraySize, uint offset)[] members)
|
||||
{
|
||||
var memberCount = (ushort)members.Length;
|
||||
var headerSize = 12;
|
||||
var memberBlockSize = 8;
|
||||
var blocksSize = memberBlockSize * members.Length;
|
||||
|
||||
var stringsBuf = new MemoryStream();
|
||||
void AppendString(string s)
|
||||
{
|
||||
var bytes = Encoding.ASCII.GetBytes(s + ";\0");
|
||||
stringsBuf.Write(bytes, 0, bytes.Length);
|
||||
}
|
||||
AppendString(udtName);
|
||||
foreach (var m in members) AppendString(m.name);
|
||||
var strings = stringsBuf.ToArray();
|
||||
|
||||
var buf = new byte[headerSize + blocksSize + strings.Length];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), memberCount);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 0x1234);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), instanceSize);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(8), 0);
|
||||
|
||||
for (var i = 0; i < members.Length; i++)
|
||||
{
|
||||
var o = headerSize + (i * memberBlockSize);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), members[i].info);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o + 2), members[i].arraySize);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), members[i].offset);
|
||||
}
|
||||
Buffer.BlockCopy(strings, 0, buf, headerSize + blocksSize, strings.Length);
|
||||
return buf;
|
||||
}
|
||||
=> TemplateBufferBuilder.Build(udtName, instanceSize,
|
||||
[.. members.Select(m => (m.name, m.info, m.arraySize, m.offset))]);
|
||||
|
||||
/// <summary>Verifies that a simple UDT with two members decodes correctly.</summary>
|
||||
[Fact]
|
||||
@@ -161,8 +131,9 @@ public sealed class CipTemplateObjectDecoderTests
|
||||
[Fact]
|
||||
public void Zero_member_count_returns_null()
|
||||
{
|
||||
var buf = new byte[12];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), 0);
|
||||
var buf = new byte[14];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(6), 8); // instance size
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(10), 0); // member count
|
||||
CipTemplateObjectDecoder.Decode(buf).ShouldBeNull();
|
||||
}
|
||||
|
||||
@@ -178,19 +149,10 @@ public sealed class CipTemplateObjectDecoderTests
|
||||
public void Missing_member_name_surfaces_placeholder()
|
||||
{
|
||||
// Header says 3 members but strings list has only UDT name + 2 member names.
|
||||
var memberCount = (ushort)3;
|
||||
var buf = new byte[12 + 8 * 3];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), memberCount);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), 12);
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var o = 12 + i * 8;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), 0xC4);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), (uint)(i * 4));
|
||||
}
|
||||
// strings: only UDT + 2 members, missing the third.
|
||||
var strings = Encoding.ASCII.GetBytes("MyUdt;\0A;\0B;\0");
|
||||
var combined = buf.Concat(strings).ToArray();
|
||||
var full = TemplateBufferBuilder.Build("MyUdt", 12,
|
||||
("A", 0xC4, 0, 0), ("B", 0xC4, 0, 4), ("C", 0xC4, 0, 8));
|
||||
// Drop the trailing "C\0" so only two member names remain.
|
||||
var combined = full[..^2];
|
||||
|
||||
var shape = CipTemplateObjectDecoder.Decode(combined);
|
||||
shape.ShouldNotBeNull();
|
||||
@@ -198,19 +160,20 @@ public sealed class CipTemplateObjectDecoderTests
|
||||
shape.Members[2].Name.ShouldBe("<member_2>");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that semicolon-terminated string parsing handles various input shapes.</summary>
|
||||
/// <param name="input">The raw semicolon-terminated string input.</param>
|
||||
/// <param name="expected">The expected array of parsed strings.</param>
|
||||
/// <summary>Verifies the template string section splits into the type name + NUL-terminated member names.</summary>
|
||||
/// <param name="input">The raw string section.</param>
|
||||
/// <param name="memberCount">Member names to read after the type name.</param>
|
||||
/// <param name="expected">The expected type name followed by member names.</param>
|
||||
[Theory]
|
||||
[InlineData("Foo;\0Bar;\0", new[] { "Foo", "Bar" })]
|
||||
[InlineData("Foo;Bar;", new[] { "Foo", "Bar" })] // no nulls
|
||||
[InlineData("Only;\0", new[] { "Only" })]
|
||||
[InlineData(";\0", new string[] { })] // empty
|
||||
[InlineData("", new string[] { })]
|
||||
public void ParseSemicolonTerminatedStrings_handles_shapes(string input, string[] expected)
|
||||
[InlineData("STRING;nEAIA\0LEN\0DATA\0", 2, new[] { "STRING", "LEN", "DATA" })]
|
||||
[InlineData("Udt;n\0A\0B\0\0\u0001junk", 2, new[] { "Udt", "A", "B" })] // trailing bytes ignored
|
||||
[InlineData("Udt\0A\0", 1, new[] { "Udt", "A" })] // no ';' tail
|
||||
[InlineData("Udt;nEA\0A\0", 3, new[] { "Udt", "A" })] // fewer names than members
|
||||
[InlineData("", 1, new string[] { })]
|
||||
public void ParseTemplateStrings_handles_shapes(string input, int memberCount, string[] expected)
|
||||
{
|
||||
var bytes = Encoding.ASCII.GetBytes(input);
|
||||
var result = CipTemplateObjectDecoder.ParseSemicolonTerminatedStrings(bytes);
|
||||
var result = CipTemplateObjectDecoder.ParseTemplateStrings(bytes, memberCount);
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,12 @@ internal class FakeAbCipTag : IAbCipTagRuntime
|
||||
/// <summary>Returns the simulated status code.</summary>
|
||||
public virtual int GetStatus() => Status;
|
||||
|
||||
/// <summary>Gets or sets the simulated read-buffer size in bytes (-1 = unknown, the default).</summary>
|
||||
public int Size { get; set; } = -1;
|
||||
|
||||
/// <summary>Returns the simulated read-buffer size.</summary>
|
||||
public virtual int GetSize() => Size;
|
||||
|
||||
/// <summary>Returns the mock tag value.</summary>
|
||||
/// <param name="type">The data type being decoded.</param>
|
||||
/// <param name="bitIndex">The optional bit index for bit operations.</param>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Builds an <c>@udt/<id></c> template buffer in the layout libplctag really returns —
|
||||
/// the layout pinned by the real-controller captures in <c>CipTemplateObjectWireCaptureTests</c>.
|
||||
/// Keep it in step with those captures, never with the decoder: a builder that restates the
|
||||
/// decoder's assumption proves nothing (#468, and the template decoder after it).
|
||||
/// </summary>
|
||||
internal static class TemplateBufferBuilder
|
||||
{
|
||||
/// <summary>Build a template buffer.</summary>
|
||||
/// <param name="udtName">UDT type name (written as <c>name;n</c> + an encoded tail, as a controller does).</param>
|
||||
/// <param name="instanceSize">Bytes per instance.</param>
|
||||
/// <param name="members">Per member: name, CIP type word (atomic code, or <c>0x8000 | nestedTemplateId</c>),
|
||||
/// array length (0 = scalar; > 0 sets the array flag and the info word), byte offset.</param>
|
||||
/// <returns>The buffer.</returns>
|
||||
public static byte[] Build(
|
||||
string udtName, uint instanceSize, params (string name, ushort type, ushort arraySize, uint offset)[] members)
|
||||
{
|
||||
const int header = 14;
|
||||
var buf = new MemoryStream();
|
||||
var head = new byte[header + (8 * members.Length)];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(head.AsSpan(0), 0x0123); // template id
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(head.AsSpan(2), 0); // def size (words)
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(head.AsSpan(6), instanceSize);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(head.AsSpan(10), (ushort)members.Length);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(head.AsSpan(12), 0xABCD); // struct handle
|
||||
for (var i = 0; i < members.Length; i++)
|
||||
{
|
||||
var o = header + (i * 8);
|
||||
var (_, type, arraySize, offset) = members[i];
|
||||
var typeWord = arraySize > 0 ? (ushort)(type | 0x2000) : type;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(head.AsSpan(o), arraySize);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(head.AsSpan(o + 2), typeWord);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(head.AsSpan(o + 4), offset);
|
||||
}
|
||||
buf.Write(head);
|
||||
buf.Write(Encoding.ASCII.GetBytes(udtName + ";nEAIA\0"));
|
||||
foreach (var m in members) buf.Write(Encoding.ASCII.GetBytes(m.name + "\0"));
|
||||
return buf.ToArray();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user