feat(abcip): native tag runtime over the managed CIP transport, with returned-type checking

This commit is contained in:
Joseph Doherty
2026-10-07 16:47:48 -04:00
parent b82fffd269
commit 25cc836865
8 changed files with 857 additions and 5 deletions
@@ -0,0 +1,314 @@
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Task 12: the native tag runtime over <see cref="FakeCipChannel"/>. Reply buffers are hard-coded
/// bytes in the layout libplctag hands its getters (the Read Tag Fragmented reply data after the
/// type header), so each decode assertion is the value <see cref="LibplctagTagRuntime"/> would return
/// for the same buffer.
/// </summary>
public sealed class NativeAbCipTagRuntimeTests
{
private static AbCipTagCreateParams Params(
string tag, AbCipDataType? type, int elementCount = 1, bool isArray = false,
string plc = "controllogix", string cipPath = "1,0", bool connected = true) => new(
Gateway: "10.0.0.5",
Port: 44818,
CipPath: cipPath,
LibplctagPlcAttribute: plc,
TagName: tag,
Timeout: TimeSpan.FromSeconds(2),
AllowPacking: false,
ElementCount: elementCount,
IsArray: isArray,
AuthoredType: type,
UseConnectedMessaging: connected);
/// <summary>A Read Tag Fragmented (0xD2) success reply: type code (+ handle) then data.</summary>
private static FakeCipChannel Replying(string typeHex, string dataHex) => new()
{
Respond = _ => Task.FromResult(Convert.FromHexString("D2000000" + typeHex + dataHex)),
};
private static async Task<NativeAbCipTagRuntime> ReadAsync(FakeCipChannel ch, AbCipTagCreateParams p)
{
var rt = NativeAbCipTagRuntime.ForTest(ch, p);
await rt.InitializeAsync(CancellationToken.None);
await rt.ReadAsync(CancellationToken.None);
return rt;
}
[Theory]
[InlineData(AbCipDataType.Bool, "C100", "01", true)]
[InlineData(AbCipDataType.SInt, "C200", "FE", -2)]
[InlineData(AbCipDataType.USInt, "C600", "FE", 254)]
[InlineData(AbCipDataType.Int, "C300", "FEFF", -2)]
[InlineData(AbCipDataType.UInt, "C700", "FEFF", 65534)]
[InlineData(AbCipDataType.DInt, "C400", "FEFFFFFF", -2)]
[InlineData(AbCipDataType.UDInt, "C800", "FEFFFFFF", 4294967294u)]
[InlineData(AbCipDataType.LInt, "C500", "FEFFFFFFFFFFFFFF", -2L)]
[InlineData(AbCipDataType.ULInt, "C900", "FEFFFFFFFFFFFFFF", 18446744073709551614UL)]
[InlineData(AbCipDataType.Real, "CA00", "0000803F", 1f)]
[InlineData(AbCipDataType.LReal, "CB00", "000000000000F03F", 1d)]
[InlineData(AbCipDataType.Dt, "C400", "40E20100", 123456)]
public async Task Each_atomic_type_decodes_exactly_as_libplctag_would(
AbCipDataType type, string typeHex, string dataHex, object expected)
{
using var rt = await ReadAsync(Replying(typeHex, dataHex), Params("T", type));
rt.GetStatus().ShouldBe((int)AbStatus.Ok);
rt.GetSize().ShouldBe(dataHex.Length / 2);
rt.DecodeValue(type, null).ShouldBe(expected);
}
[Fact]
public async Task A_Logix_STRING_decodes_LEN_then_ASCII_DATA()
{
// Structure 0x02A0 + handle 0x0FCE, then LEN=7 "HelloAB" in an 88-byte STRING.
var data = "07000000" + Convert.ToHexString(Encoding.ASCII.GetBytes("HelloAB")) + new string('0', 2 * 77);
using var rt = await ReadAsync(Replying("A002CE0F", data), Params("S", AbCipDataType.String));
rt.DecodeValue(AbCipDataType.String, null).ShouldBe("HelloAB");
rt.StructHandle.ShouldBe((ushort)0x0FCE);
}
[Fact]
public async Task A_STRING_whose_LEN_overruns_the_buffer_is_clamped_to_the_data()
{
using var rt = await ReadAsync(Replying("A002CE0F", "FF000000414243"), Params("S", AbCipDataType.String));
rt.DecodeValue(AbCipDataType.String, null).ShouldBe("ABC");
}
[Fact]
public async Task A_bit_index_reads_that_bit_of_the_little_endian_buffer()
{
// 0x00000100 → bit 8 set, bit 3 clear.
using var rt = await ReadAsync(Replying("C400", "00010000"), Params("Flags", null));
rt.DecodeValue(AbCipDataType.Bool, 8).ShouldBe(true);
rt.DecodeValue(AbCipDataType.Bool, 3).ShouldBe(false);
}
[Fact]
public async Task A_BOOL_bit_selector_against_a_DINT_host_is_not_a_type_mismatch()
{
var ch = Replying("C400", "08000000");
using var rt = await ReadAsync(ch, Params("Flags.3", AbCipDataType.Bool));
rt.ImplicitBit.ShouldBe(3);
rt.GetStatus().ShouldBe((int)AbStatus.Ok);
rt.DecodeValue(AbCipDataType.Bool, 3).ShouldBe(true);
// The bit selector is stripped from the encoded path: the request names only "Flags".
ch.Sent[0].ShouldBe(Convert.FromHexString("5204" + "9105466C61677300" + "0100" + "00000000"));
}
[Fact]
public async Task An_authored_BOOL_answered_as_a_packed_BOOL_array_DWORD_is_not_a_type_mismatch()
{
using var rt = await ReadAsync(Replying("D300", "01000000"), Params("Bits[0]", AbCipDataType.Bool));
rt.GetStatus().ShouldBe((int)AbStatus.Ok);
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true);
}
[Fact]
public async Task Real_returned_for_a_DInt_authored_tag_is_TypeMismatch()
{
using var rt = await ReadAsync(Replying("CA00", "0000803F"), Params("Speed", AbCipDataType.DInt));
rt.GetStatus().ShouldBe((int)AbStatus.TypeMismatch);
rt.DecodeValue(AbCipDataType.DInt, null).ShouldBeNull();
rt.DecodeValueAt(AbCipDataType.DInt, 0, null).ShouldBeNull();
rt.DecodeArray(AbCipDataType.DInt, 1).ShouldBeNull();
}
[Fact]
public async Task A_structure_reply_is_never_a_type_mismatch()
{
using var rt = await ReadAsync(Replying("A002CE0F", "2A000000"), Params("Udt", AbCipDataType.DInt));
rt.GetStatus().ShouldBe((int)AbStatus.Ok);
}
[Fact]
public async Task Unknown_tag_fails_initialize_with_NotFound()
{
var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString("D2000400")) };
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Nope", AbCipDataType.DInt));
(await Should.ThrowAsync<AbTransportException>(() => rt.InitializeAsync(CancellationToken.None)))
.Status.ShouldBe(AbStatus.NotFound);
rt.GetStatus().ShouldBe((int)AbStatus.NotFound);
}
[Fact]
public async Task A_one_element_array_is_read_as_an_array()
{
var ch = Replying("C400", "2A000000");
using var rt = await ReadAsync(ch, Params("Arr", AbCipDataType.DInt, elementCount: 1, isArray: true));
rt.DecodeArray(AbCipDataType.DInt, 1).ShouldBe(new[] { 42 });
}
[Fact]
public async Task An_array_read_requests_every_element_and_decodes_at_the_buffer_stride()
{
var ch = Replying("C400", "01000000" + "02000000" + "03000000");
using var rt = await ReadAsync(ch, Params("Arr", AbCipDataType.DInt, elementCount: 3, isArray: true));
rt.DecodeArray(AbCipDataType.DInt, 3).ShouldBe(new[] { 1, 2, 3 });
ch.Sent[0].ShouldBe(Convert.FromHexString("5203" + "9103417272" + "00" + "0300" + "00000000"));
}
[Fact]
public async Task A_BOOL_array_is_bit_packed()
{
using var rt = await ReadAsync(Replying("D300", "05000000"), Params("Bits", AbCipDataType.Bool, 3, true));
rt.DecodeArray(AbCipDataType.Bool, 3).ShouldBe(new[] { true, false, true });
}
[Fact]
public async Task A_user_string_array_uses_the_buffer_derived_stride()
{
// Two STR0016-style elements of 20 bytes (LEN + 16 DATA), not the static 88.
static string Element(string s) =>
Convert.ToHexString(LittleEndianU32(s.Length)) + Convert.ToHexString(Encoding.ASCII.GetBytes(s)).PadRight(32, '0');
using var rt = await ReadAsync(
Replying("A002AAAA", Element("ab") + Element("xyz")), Params("Strs", AbCipDataType.String, 2, true));
rt.DecodeArray(AbCipDataType.String, 2).ShouldBe(new[] { "ab", "xyz" });
}
private static byte[] LittleEndianU32(int v) => [(byte)v, (byte)(v >> 8), (byte)(v >> 16), (byte)(v >> 24)];
[Fact]
public async Task Decoding_past_the_buffer_is_OutOfBounds_as_libplctag_reports_it()
{
using var rt = await ReadAsync(Replying("C400", "2A000000"), Params("T", AbCipDataType.DInt));
Should.Throw<AbTransportException>(() => rt.DecodeValueAt(AbCipDataType.DInt, 2, null))
.Status.ShouldBe(AbStatus.OutOfBounds);
}
[Fact]
public async Task A_DINT_write_sends_Write_Tag_with_the_read_type()
{
var ch = Replying("C400", "00000000");
using var rt = await ReadAsync(ch, Params("T", AbCipDataType.DInt));
ch.Respond = req => Task.FromResult(FakeCipChannel.OkReply(req));
rt.EncodeValue(AbCipDataType.DInt, null, 42);
await rt.WriteAsync(CancellationToken.None);
rt.GetStatus().ShouldBe((int)AbStatus.Ok);
ch.Sent[^1].ShouldBe(Convert.FromHexString("4D02" + "91015400" + "C400" + "0100" + "2A000000"));
}
[Fact]
public async Task A_STRING_write_sends_Struct_and_the_handle_learned_from_the_prior_read()
{
var ch = Replying("A002CE0F", "00000000" + new string('0', 2 * 84));
using var rt = await ReadAsync(ch, Params("S", AbCipDataType.String));
ch.Respond = req => Task.FromResult(FakeCipChannel.OkReply(req));
rt.EncodeValue(AbCipDataType.String, null, "Hi");
await rt.WriteAsync(CancellationToken.None);
var expected = "4D02" + "91015300" + "A002" + "CE0F" + "0100"
+ "02000000" + "4869" + new string('0', 2 * 82);
ch.Sent[^1].ShouldBe(Convert.FromHexString(expected));
}
[Fact]
public async Task A_STRING_write_matches_the_libplctag_capture_when_the_server_reports_type_D0()
{
// Captures/logix-write-string.hex: ab_server reports a STRING as type 0x00D0 with no handle, and
// libplctag writes it back as D0 00, element count 1, LEN + DATA padded to the 88-byte read size.
var ch = Replying("D000", new string('0', 2 * 88));
using var rt = await ReadAsync(ch, Params("TestString", AbCipDataType.String));
ch.Respond = req => Task.FromResult(FakeCipChannel.OkReply(req));
rt.EncodeValue(AbCipDataType.String, null, "HelloAB");
await rt.WriteAsync(CancellationToken.None);
var captured = "4D06910A54657374537472696E67D00001000700000048656C6C6F4142" + new string('0', 2 * 77);
ch.Sent[^1].ShouldBe(Convert.FromHexString(captured));
}
[Fact]
public async Task A_STRING_longer_than_the_learned_capacity_is_TooLarge()
{
var ch = Replying("A002CE0F", "00000000" + "00000000");
using var rt = await ReadAsync(ch, Params("S", AbCipDataType.String));
rt.EncodeValue(AbCipDataType.String, null, "toolong");
(await Should.ThrowAsync<AbTransportException>(() => rt.WriteAsync(CancellationToken.None)))
.Status.ShouldBe(AbStatus.TooLarge);
rt.GetStatus().ShouldBe((int)AbStatus.TooLarge);
}
[Fact]
public async Task A_write_whose_width_disagrees_with_the_controller_type_is_TypeMismatch_and_sends_nothing()
{
var ch = Replying("C400", "00000000");
using var rt = await ReadAsync(ch, Params("T", null));
var sentBefore = ch.SentCount;
rt.EncodeValue(AbCipDataType.Int, null, 1);
(await Should.ThrowAsync<AbTransportException>(() => rt.WriteAsync(CancellationToken.None)))
.Status.ShouldBe(AbStatus.TypeMismatch);
ch.SentCount.ShouldBe(sentBefore);
}
[Fact]
public async Task A_refused_write_stores_the_status_and_rethrows()
{
var ch = Replying("C400", "00000000");
using var rt = await ReadAsync(ch, Params("T", AbCipDataType.DInt));
ch.Respond = req => Task.FromResult(FakeCipChannel.Reply(req[0], 0x0F, []));
rt.EncodeValue(AbCipDataType.DInt, null, 1);
(await Should.ThrowAsync<AbTransportException>(() => rt.WriteAsync(CancellationToken.None)))
.Status.ShouldBe(AbStatus.NotAllowed);
rt.GetStatus().ShouldBe((int)AbStatus.NotAllowed);
}
[Fact]
public void Encode_refuses_the_same_cases_as_libplctag()
{
using var rt = NativeAbCipTagRuntime.ForTest(new FakeCipChannel(), Params("T", AbCipDataType.Bool));
Should.Throw<NotSupportedException>(() => rt.EncodeValue(AbCipDataType.Bool, 3, true));
Should.Throw<NotSupportedException>(() => rt.EncodeValue(AbCipDataType.Structure, null, 1));
Should.Throw<NotSupportedException>(() => rt.EncodeValue((AbCipDataType)999, null, 1));
}
[Fact]
public void The_device_key_defaults_the_port_and_keeps_path_and_family()
{
NativeAbCipDevice.Key(Params("T", null) with { Port = 0 })
.ShouldBe(new DeviceKey("10.0.0.5", 44818, "1,0", "controllogix"));
NativeAbCipDevice.Key(Params("T", null) with { Port = 2222 }).Port.ShouldBe(2222);
}
[Fact]
public void Connection_options_carry_timeout_size_and_packing()
{
var o = NativeAbCipDevice.Options(Params("T", null) with { ConnectionSize = 504, AllowPacking = true });
o.Timeout.ShouldBe(TimeSpan.FromSeconds(2));
o.ConnectionSize.ShouldBe(504);
o.AllowPacking.ShouldBeTrue();
}
// Routing cross-check against Captures/README.md "Routing observed" (libplctag 2.6.0 on the wire):
// controllogix (path=1,0) → Large Forward Open, requests connected ⇒ Connected — matches.
// micro800 (no path) → Large Forward Open, path 20 02 24 01 ⇒ Connected — matches,
// even though the Micro800 profile says SupportsConnectedMessaging=false (ruling R12-1: the
// capture micro800-read-dint.hex beats the profile flag; the brief's UseConnectedMessaging rule
// alone would have chosen Direct, which is a MISMATCH with the capture).
// The UnconnectedSend / Direct rows below have no Logix-family capture; they follow the brief's rule.
[Theory]
[InlineData("controllogix", "1,0", true, CipRouting.Connected)]
[InlineData("compactlogix", "1,0", true, CipRouting.Connected)]
[InlineData("controllogix", "1,0", false, CipRouting.UnconnectedSend)]
[InlineData("controllogix", "", false, CipRouting.Direct)]
[InlineData("micro800", "", false, CipRouting.Connected)]
[InlineData("micro800", "", true, CipRouting.Connected)]
public void Routing_follows_the_family_profile_with_Micro800_always_connected(
string plc, string cipPath, bool connected, CipRouting expected) =>
NativeAbCipDevice.Options(Params("T", null, plc: plc, cipPath: cipPath, connected: connected))
.Routing.ShouldBe(expected);
[Fact]
public void The_factory_creates_native_runtimes()
{
using var rt = new NativeAbCipTagFactory().Create(Params("T", AbCipDataType.DInt));
rt.ShouldBeOfType<NativeAbCipTagRuntime>();
}
}
@@ -28,6 +28,12 @@
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj"/>
</ItemGroup>
<ItemGroup>
<!-- Native transport test fake, source-linked so it stays internal (Task 12). -->
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests\Fakes\FakeCipChannel.cs" Link="Fakes\FakeCipChannel.cs" />
</ItemGroup>
</Project>