diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionManagerServices.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionManagerServices.cs
new file mode 100644
index 00000000..db6156a4
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionManagerServices.cs
@@ -0,0 +1,72 @@
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+
+/// Parameters for a (Large) Forward Open. is the maximum CIP
+/// connection payload in bytes (e.g. 4002 for Large, 504 for regular).
+public sealed record ForwardOpenParams(
+ bool Large, uint TOConnectionId, ushort ConnectionSerial, ushort VendorId, uint OriginatorSerial,
+ byte TimeoutMultiplier, uint RpiMicros, int ConnectionSize, byte[] Route);
+
+public sealed record ForwardOpenReply(uint OTConnectionId, uint TOConnectionId, uint OTApiMicros, uint TOApiMicros);
+
+/// Connection Manager (class 6) request builders and reply parsers.
+public static class ConnectionManagerServices
+{
+ /// Priority/time-tick byte.
+ public const byte PriorityTick = 0x0A;
+
+ /// Timeout ticks. The brief proposed 0x0E; every libplctag capture (Forward Open, Forward
+ /// Close, Unconnected Send) carries 0x05, and the capture wins.
+ public const byte TimeoutTicks = 0x05;
+
+ public static byte[] UnconnectedSend(ReadOnlySpan embedded, ReadOnlySpan route)
+ {
+ var w = new CipWriter().U8(0x52).U8(2).Bytes(CipPath.ConnectionManager)
+ .U8(PriorityTick).U8(TimeoutTicks).U16((ushort)embedded.Length).Bytes(embedded);
+ if ((embedded.Length & 1) == 1) w.U8(0);
+ return w.U8((byte)(route.Length / 2)).U8(0).Bytes(route).ToArray();
+ }
+
+ public static byte[] ForwardOpen(ForwardOpenParams p)
+ {
+ var connPath = new CipWriter().Bytes(p.Route).Bytes(CipPath.MessageRouter).ToArray();
+ var w = new CipWriter().U8(p.Large ? (byte)0x5B : (byte)0x54).U8(2).Bytes(CipPath.ConnectionManager)
+ .U8(PriorityTick).U8(TimeoutTicks)
+ .U32(0).U32(p.TOConnectionId).U16(p.ConnectionSerial).U16(p.VendorId).U32(p.OriginatorSerial)
+ .U8(p.TimeoutMultiplier).U8(0).U8(0).U8(0);
+ WriteSide(w, p);
+ WriteSide(w, p);
+ return w.U8(0xA3).U8((byte)(connPath.Length / 2)).Bytes(connPath).ToArray();
+ }
+
+ private static void WriteSide(CipWriter w, ForwardOpenParams p)
+ {
+ w.U32(p.RpiMicros);
+ if (p.Large) w.U32(0x42000000u | (uint)p.ConnectionSize);
+ else w.U16((ushort)(0x4200 | (p.ConnectionSize & 0x1FF)));
+ }
+
+ public static ForwardOpenReply ParseForwardOpenReply(CipReply reply)
+ {
+ if (reply.GeneralStatus != 0)
+ throw new AbTransportException(reply.Status == AbStatus.BadReply ? AbStatus.BadConnection : reply.Status,
+ $"Forward Open refused: general 0x{reply.GeneralStatus:X2}, extended {string.Join(",", reply.ExtendedStatus.Select(e => $"0x{e:X4}"))}.");
+ var r = new CipReader(reply.Data.Span);
+ var ot = r.U32(); var to = r.U32(); r.U16(); r.U16(); r.U32();
+ return new ForwardOpenReply(ot, to, r.U32(), r.U32());
+ }
+
+ /// True when a refused Large Forward Open should be retried as a regular one: general 0x08
+ /// (Service Not Supported — pre-v20 Logix) or general 0x01 / extended 0x0109 (invalid connection size).
+ public static bool ShouldFallBackToRegularOpen(CipReply r) =>
+ r.GeneralStatus == 0x08
+ || (r.GeneralStatus == 0x01 && r.ExtendedStatus.Length > 0 && r.ExtendedStatus[0] == 0x0109);
+
+ public static byte[] ForwardClose(ushort connectionSerial, ushort vendorId, uint originatorSerial, ReadOnlySpan route)
+ {
+ var connPath = new CipWriter().Bytes(route).Bytes(CipPath.MessageRouter).ToArray();
+ return new CipWriter().U8(0x4E).U8(2).Bytes(CipPath.ConnectionManager).U8(PriorityTick).U8(TimeoutTicks)
+ .U16(connectionSerial).U16(vendorId).U32(originatorSerial)
+ .U8((byte)(connPath.Length / 2)).U8(0).Bytes(connPath).ToArray();
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/MultipleServicePacket.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/MultipleServicePacket.cs
new file mode 100644
index 00000000..053e00b8
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/MultipleServicePacket.cs
@@ -0,0 +1,47 @@
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+
+/// Multiple Service Packet (service 0x0A on the Message Router).
+public static class MultipleServicePacket
+{
+ /// Service + path size + Message Router path.
+ private const int HeaderSize = 6;
+
+ public static byte[] Build(IReadOnlyList requests)
+ {
+ var w = new CipWriter().U8(0x0A).U8(2).Bytes(CipPath.MessageRouter).U16((ushort)requests.Count);
+ var offset = 2 + 2 * requests.Count; // measured from the start of the count field
+ foreach (var r in requests) { w.U16((ushort)offset); offset += r.Length; }
+ foreach (var r in requests) w.Bytes(r);
+ return w.ToArray();
+ }
+
+ /// Exact byte size of an MSP carrying requests of the given sizes.
+ public static int BuildSize(IReadOnlyList requestSizes)
+ {
+ var total = HeaderSize + 2 + 2 * requestSizes.Count;
+ foreach (var s in requestSizes) total += s;
+ return total;
+ }
+
+ /// Splits an MSP reply (the outer reply's data) into the embedded replies.
+ public static IReadOnlyList Parse(CipReply reply, int count)
+ {
+ var data = reply.Data;
+ var r = new CipReader(data.Span);
+ var n = r.U16();
+ if (n != count)
+ throw new AbTransportException(AbStatus.BadReply, $"MSP reply carries {n} replies, expected {count}.");
+ var offsets = new int[n];
+ for (var i = 0; i < n; i++) offsets[i] = r.U16();
+ var result = new CipReply[n];
+ for (var i = 0; i < n; i++)
+ {
+ var end = i + 1 < n ? offsets[i + 1] : data.Length;
+ if (offsets[i] < 2 + 2 * n || end < offsets[i] || end > data.Length)
+ throw new AbTransportException(AbStatus.BadReply, $"MSP reply offset {offsets[i]}..{end} is out of range.");
+ result[i] = CipReply.Parse(data[offsets[i]..end]);
+ }
+ return result;
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixServices.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixServices.cs
new file mode 100644
index 00000000..0637cf56
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixServices.cs
@@ -0,0 +1,37 @@
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
+
+/// Logix Read/Write Tag (plain and fragmented) builders and the read-reply parser.
+public static class LogixServices
+{
+ private static CipWriter Start(byte service, ReadOnlySpan path) =>
+ new CipWriter().U8(service).U8((byte)(path.Length / 2)).Bytes(path);
+
+ public static byte[] ReadTag(ReadOnlySpan path, ushort elements) => Start(0x4C, path).U16(elements).ToArray();
+
+ public static byte[] ReadTagFragmented(ReadOnlySpan path, ushort elements, uint offset) =>
+ Start(0x52, path).U16(elements).U32(offset).ToArray();
+
+ public static byte[] WriteTag(ReadOnlySpan path, ushort type, ushort? handle, ushort elements, ReadOnlySpan data)
+ {
+ var w = Start(0x4D, path).U16(type);
+ if (type == LogixTypeCode.Struct) w.U16(handle ?? throw new ArgumentException("Struct write needs a handle."));
+ return w.U16(elements).Bytes(data).ToArray();
+ }
+
+ public static byte[] WriteTagFragmented(ReadOnlySpan path, ushort type, ushort? handle, ushort elements, uint offset, ReadOnlySpan data)
+ {
+ var w = Start(0x53, path).U16(type);
+ if (type == LogixTypeCode.Struct) w.U16(handle ?? throw new ArgumentException("Struct write needs a handle."));
+ return w.U16(elements).U32(offset).Bytes(data).ToArray();
+ }
+
+ public static (ushort TypeCode, ushort? StructHandle, ReadOnlyMemory Data) ParseReadReply(CipReply reply)
+ {
+ var r = new CipReader(reply.Data.Span);
+ var type = r.U16();
+ ushort? handle = type == LogixTypeCode.Struct ? r.U16() : null;
+ return (type, handle, reply.Data[r.Position..]);
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/SymbolObjectServices.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/SymbolObjectServices.cs
new file mode 100644
index 00000000..dead6319
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/SymbolObjectServices.cs
@@ -0,0 +1,44 @@
+using System.Text;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
+
+/// Symbol Object (class 0x6B) instance listing — the @tags enumeration.
+public static class SymbolObjectServices
+{
+ /// Request attributes: 2 = type, 7 = element length, 8 = array dims, 1 = name. Identical
+ /// to what libplctag sends (logix-tags-listing.hex).
+ public static byte[] GetInstanceAttributeList(string? programName, uint startInstance)
+ {
+ var path = new CipWriter();
+ if (!string.IsNullOrEmpty(programName))
+ {
+ var a = Encoding.ASCII.GetBytes(programName.StartsWith("Program:", StringComparison.Ordinal) ? programName : "Program:" + programName);
+ path.U8(0x91).U8((byte)a.Length).Bytes(a).PadToEven();
+ }
+ path.U8(0x20).U8(0x6B).U8(0x25).U8(0).U16((ushort)startInstance);
+ var p = path.ToArray();
+ return new CipWriter().U8(0x55).U8((byte)(p.Length / 2)).Bytes(p)
+ .U16(4).U16(2).U16(7).U16(8).U16(1).ToArray();
+ }
+
+ /// Entries are u32 instance, u16 type, u16 elemLen, u32×3 dims, u16 nameLen, name.
+ /// LastInstance is the last entry's instance id (next start = LastInstance + 1).
+ /// More is true on general status 0x06 (partial transfer).
+ public static (byte[] Entries, uint LastInstance, bool More) ParseListingReply(CipReply reply)
+ {
+ if (reply.GeneralStatus is not (0x00 or 0x06))
+ throw new AbTransportException(reply.Status, $"Symbol listing failed: general 0x{reply.GeneralStatus:X2}.");
+ var span = reply.Data.Span;
+ var r = new CipReader(span);
+ uint last = 0;
+ while (r.Remaining > 0)
+ {
+ last = r.U32();
+ r.Bytes(2 + 2 + 12);
+ var nameLen = r.U16();
+ r.Bytes(nameLen);
+ }
+ return (span.ToArray(), last, reply.GeneralStatus == 0x06);
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/TemplateObjectServices.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/TemplateObjectServices.cs
new file mode 100644
index 00000000..473e8804
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/TemplateObjectServices.cs
@@ -0,0 +1,52 @@
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
+
+public sealed record TemplateAttributes(uint DefinitionWords, uint StructureBytes, ushort Members, ushort Handle);
+
+/// Template Object (class 0x6C) attribute query and definition read.
+public static class TemplateObjectServices
+{
+ /// Get Attribute List for attributes 4 (definition words), 5 (structure bytes),
+ /// 2 (member count) and 1 (handle).
+ public static byte[] GetAttributes(ushort templateId) =>
+ new CipWriter().U8(0x03).U8(3).Bytes([0x20, 0x6C, 0x25, 0x00]).U16(templateId)
+ .U16(4).U16(4).U16(5).U16(2).U16(1).ToArray();
+
+ public static TemplateAttributes ParseAttributes(CipReply reply)
+ {
+ if (reply.GeneralStatus != 0)
+ throw new AbTransportException(reply.Status, $"Template attributes failed: general 0x{reply.GeneralStatus:X2}.");
+ var r = new CipReader(reply.Data.Span);
+ var count = r.U16();
+ uint defWords = 0, structBytes = 0; ushort members = 0, handle = 0;
+ for (var i = 0; i < count; i++)
+ {
+ var id = r.U16(); var status = r.U16();
+ if (status != 0)
+ throw new AbTransportException(AbStatus.BadReply, $"Template attribute {id} status 0x{status:X4}.");
+ switch (id)
+ {
+ case 4: defWords = r.U32(); break;
+ case 5: structBytes = r.U32(); break;
+ case 2: members = r.U16(); break;
+ case 1: handle = r.U16(); break;
+ default: throw new AbTransportException(AbStatus.BadReply, $"Unexpected template attribute {id}.");
+ }
+ }
+ return new TemplateAttributes(defWords, structBytes, members, handle);
+ }
+
+ /// Read Template: 4C 03 20 6C 25 00 id offset bytes.
+ public static byte[] ReadTemplate(ushort templateId, uint offset, ushort bytes) =>
+ new CipWriter().U8(0x4C).U8(3).Bytes([0x20, 0x6C, 0x25, 0x00]).U16(templateId).U32(offset).U16(bytes).ToArray();
+
+ /// More ⇔ general status 0x06.
+ public static bool ReadHasMore(CipReply reply) => reply.GeneralStatus == 0x06;
+
+ /// The buffer CipTemplateObjectDecoder expects: u16 id, u32 defWords,
+ /// u32 structBytes, u16 members, u16 handle then the raw template bytes.
+ public static byte[] BuildDecoderBuffer(ushort templateId, TemplateAttributes attrs, ReadOnlySpan rawTemplate) =>
+ new CipWriter().U16(templateId).U32(attrs.DefinitionWords).U32(attrs.StructureBytes)
+ .U16(attrs.Members).U16(attrs.Handle).Bytes(rawTemplate).ToArray();
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ConnectionManagerServicesTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ConnectionManagerServicesTests.cs
new file mode 100644
index 00000000..ad8766dc
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ConnectionManagerServicesTests.cs
@@ -0,0 +1,114 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Cip;
+
+// CAPTURE DECISION: the brief gave timeout ticks 0x0E. Every libplctag capture (logix-forward-open,
+// logix-forward-close) carries priority/tick 0x0A, timeout ticks 0x05, so ConnectionManagerServices
+// uses 0x05 (brief: 0x0E -> capture: 0x05). The golden-byte tests below use 0x05 accordingly.
+public sealed class ConnectionManagerServicesTests
+{
+ private static byte[] CapturedRequest(string file) =>
+ CaptureFile.CipPayload(CaptureFile.Load(file).First(f => f.FromClient));
+
+ private static CipReply CapturedReply(string file) =>
+ CipReply.Parse(CaptureFile.CipPayload(CaptureFile.Load(file).First(f => !f.FromClient)));
+
+ [Fact]
+ public void Forward_close_frame()
+ {
+ var bytes = ConnectionManagerServices.ForwardClose(0x1234, 0x1337, 0xCAFEBABE, [0x01, 0x00]);
+ Convert.ToHexString(bytes).ShouldBe("4E02200624010A05" + "3412" + "3713" + "BEBAFECA" + "03" + "00" + "0100" + "20022401");
+ }
+
+ [Fact]
+ public void Forward_close_matches_capture()
+ {
+ var cap = CapturedRequest("logix-forward-close.hex");
+ var serial = BitConverter.ToUInt16(cap, 8);
+ var vendor = BitConverter.ToUInt16(cap, 10);
+ var orig = BitConverter.ToUInt32(cap, 12);
+ vendor.ShouldBe((ushort)0xF33D);
+ ConnectionManagerServices.ForwardClose(serial, vendor, orig, [0x01, 0x00]).ShouldBe(cap);
+ }
+
+ [Fact]
+ public void Large_forward_open_matches_capture()
+ {
+ var cap = CapturedRequest("logix-forward-open.hex");
+ // Values read out of the capture: T->O id @12, serial @16, vendor @18, originator serial @20,
+ // timeout multiplier @24, RPI @28, network params @32 (0x42000FA2 -> size 4002).
+ var p = new ForwardOpenParams(
+ Large: true, TOConnectionId: BitConverter.ToUInt32(cap, 12), ConnectionSerial: BitConverter.ToUInt16(cap, 16),
+ VendorId: BitConverter.ToUInt16(cap, 18), OriginatorSerial: BitConverter.ToUInt32(cap, 20),
+ TimeoutMultiplier: cap[24], RpiMicros: BitConverter.ToUInt32(cap, 28), ConnectionSize: 4002, Route: [0x01, 0x00]);
+ p.RpiMicros.ShouldBe(1_000_000u);
+ BitConverter.ToUInt32(cap, 32).ShouldBe(0x42000FA2u);
+ ConnectionManagerServices.ForwardOpen(p).ShouldBe(cap);
+ }
+
+ [Fact]
+ public void Large_forward_open_reply_parses_from_capture()
+ {
+ var reply = ConnectionManagerServices.ParseForwardOpenReply(CapturedReply("logix-forward-open.hex"));
+ reply.OTConnectionId.ShouldBe(0x2CDF633Fu);
+ reply.TOConnectionId.ShouldBe(0x073F9E60u);
+ reply.OTApiMicros.ShouldBe(1_000_000u);
+ reply.TOApiMicros.ShouldBe(1_000_000u);
+ }
+
+ // No small Forward Open 0x54 was captured (the Large FO succeeded), so this layout is checked against
+ // the CIP spec / the brief rather than a capture.
+ [Fact]
+ public void Regular_forward_open_layout_per_spec()
+ {
+ var bytes = ConnectionManagerServices.ForwardOpen(new ForwardOpenParams(
+ false, 0x11223344, 0x5566, 0x7788, 0x99AABBCC, 0x01, 1_000_000, 504, [0x01, 0x00]));
+ Convert.ToHexString(bytes).ShouldBe(
+ "540220062401" + "0A05" + "00000000" + "44332211" + "6655" + "8877" + "CCBBAA99" + "01000000"
+ + "40420F00" + "F843" + "40420F00" + "F843" + "A3" + "03" + "0100" + "20022401");
+ }
+
+ [Fact]
+ public void Regular_forward_open_network_params_encoding()
+ {
+ var bytes = ConnectionManagerServices.ForwardOpen(new ForwardOpenParams(false, 1, 2, 3, 4, 1, 1_000_000, 504, []));
+ // 504 = 0x1F8; 0x4200 | 0x1F8 = 0x43F8 -> LE F8 43
+ Convert.ToHexString(bytes).ShouldContain("40420F00" + "F843");
+ bytes[0].ShouldBe((byte)0x54);
+ }
+
+ [Fact]
+ public void Unconnected_send_even_embedded()
+ {
+ byte[] embedded = [0x4C, 0x02, 0x91, 0x01, 0x41, 0x00, 0x01, 0x00]; // 8 bytes
+ Convert.ToHexString(ConnectionManagerServices.UnconnectedSend(embedded, [0x01, 0x00])).ShouldBe(
+ "520220062401" + "0A05" + "0800" + Convert.ToHexString(embedded) + "01" + "00" + "0100");
+ }
+
+ [Fact]
+ public void Unconnected_send_pads_odd_embedded()
+ {
+ byte[] embedded = [0x01, 0x02, 0x03];
+ Convert.ToHexString(ConnectionManagerServices.UnconnectedSend(embedded, [0x01, 0x00])).ShouldBe(
+ "520220062401" + "0A05" + "0300" + "010203" + "00" + "01" + "00" + "0100");
+ }
+
+ [Fact]
+ public void Refused_forward_open_surfaces_statuses()
+ {
+ var reply = CipReply.Parse(Convert.FromHexString("DB000101" + "0901"));
+ var ex = Should.Throw(() => ConnectionManagerServices.ParseForwardOpenReply(reply));
+ ex.Message.ShouldContain("0x01");
+ ex.Message.ShouldContain("0x0109");
+ }
+
+ [Theory]
+ [InlineData("DB000800", true)] // Service Not Supported
+ [InlineData("DB0001010901", true)] // general 1 / extended 0x0109
+ [InlineData("DB0001010001", false)] // general 1 / extended 0x0100 (connection in use): no fallback
+ [InlineData("DB000000", false)]
+ public void Fallback_rules(string hex, bool expected) =>
+ ConnectionManagerServices.ShouldFallBackToRegularOpen(CipReply.Parse(Convert.FromHexString(hex))).ShouldBe(expected);
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/MultipleServicePacketTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/MultipleServicePacketTests.cs
new file mode 100644
index 00000000..ad2b9547
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/MultipleServicePacketTests.cs
@@ -0,0 +1,52 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Cip;
+
+public sealed class MultipleServicePacketTests
+{
+ [Fact]
+ public void Msp_offsets_are_measured_from_the_count_field()
+ {
+ var a = new byte[] { 0x4C, 0x02, 0x91, 0x01, 0x41, 0x00, 0x01, 0x00 };
+ var b = new byte[] { 0x4C, 0x02, 0x91, 0x01, 0x42, 0x00, 0x01, 0x00 };
+ Convert.ToHexString(MultipleServicePacket.Build([a, b])).ShouldBe(
+ "0A02200224010200" + "0600" + "0E00" + Convert.ToHexString(a) + Convert.ToHexString(b));
+ MultipleServicePacket.BuildSize([8, 8]).ShouldBe(6 + 2 + 4 + 16);
+ }
+
+ [Fact]
+ public void BuildSize_equals_built_length()
+ {
+ byte[][] reqs = [new byte[7], new byte[20], new byte[1]];
+ MultipleServicePacket.Build(reqs).Length.ShouldBe(MultipleServicePacket.BuildSize([7, 20, 1]));
+ }
+
+ [Fact]
+ public void Parse_splits_replies()
+ {
+ // outer reply data: count 2, offsets 6 and 14, reply1 (8 bytes), reply2 (6 bytes)
+ var r1 = Convert.FromHexString("CC000000C400" + "0100"); // DINT-typed read reply, 8 bytes
+ var r2 = Convert.FromHexString("CD000000" + "0000"); // write ok + 2 extra bytes
+ var data = Convert.FromHexString("0200" + "0600" + "0E00").Concat(r1).Concat(r2).ToArray();
+ var outer = new CipReply(0x0A, 0, [], data);
+ var replies = MultipleServicePacket.Parse(outer, 2);
+ replies.Count.ShouldBe(2);
+ replies[0].Service.ShouldBe((byte)0x4C);
+ Convert.ToHexString(replies[0].Data.ToArray()).ShouldBe("C4000100");
+ replies[1].Service.ShouldBe((byte)0x4D);
+ }
+
+ [Fact]
+ public void Parse_embedded_error_status_is_kept_per_reply()
+ {
+ var r1 = Convert.FromHexString("CC000400");
+ var data = Convert.FromHexString("0100" + "0400").Concat(r1).ToArray();
+ MultipleServicePacket.Parse(new CipReply(0x0A, 0x1E, [], data), 1)[0].GeneralStatus.ShouldBe((byte)0x04);
+ }
+
+ [Fact]
+ public void Parse_wrong_count_throws() =>
+ Should.Throw(() =>
+ MultipleServicePacket.Parse(new CipReply(0x0A, 0, [], Convert.FromHexString("0100" + "0400" + "CC000000")), 2));
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixServicesTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixServicesTests.cs
new file mode 100644
index 00000000..7eb3c8ed
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixServicesTests.cs
@@ -0,0 +1,110 @@
+using System.Text;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Logix;
+
+// CAPTURE DECISION: libplctag sends Read Tag FRAGMENTED (0x52, offset 0) for plain reads, connected,
+// with no MSP and no Unconnected Send wrapper. The brief's "ReadTag 0x4C" comparison is therefore made
+// against ReadTagFragmented(path, 1, 0). Replies are service 0xD2 (0x52|0x80).
+public sealed class LogixServicesTests
+{
+ private static byte[] Req(string file, int i = 0) =>
+ CaptureFile.CipPayload(CaptureFile.Load(file).Where(f => f.FromClient).ElementAt(i));
+
+ private static CipReply Rep(string file, int i = 0) =>
+ CipReply.Parse(CaptureFile.CipPayload(CaptureFile.Load(file).Where(f => !f.FromClient).ElementAt(i)));
+
+ [Fact]
+ public void Read_request_matches_capture() =>
+ LogixServices.ReadTagFragmented(CipPath.EncodeSymbolic("TestDINT"), 1, 0).ShouldBe(Req("logix-read-dint.hex"));
+
+ [Fact]
+ public void Read_array_request_matches_capture() =>
+ LogixServices.ReadTagFragmented(CipPath.EncodeSymbolic("TestArray"), 16, 0).ShouldBe(Req("logix-read-dint-array16.hex"));
+
+ [Fact]
+ public void Read_dint_value()
+ {
+ var (type, handle, data) = LogixServices.ParseReadReply(Rep("logix-read-dint.hex"));
+ type.ShouldBe(LogixTypeCode.DInt);
+ handle.ShouldBeNull();
+ data.Length.ShouldBe(4);
+ BitConverter.ToInt32(data.Span).ShouldBe(123456);
+ }
+
+ [Fact]
+ public void Read_array_values()
+ {
+ var (type, _, data) = LogixServices.ParseReadReply(Rep("logix-read-dint-array16.hex"));
+ type.ShouldBe(LogixTypeCode.DInt);
+ data.Length.ShouldBe(64);
+ for (var i = 0; i < 16; i++)
+ BitConverter.ToUInt32(data.Span[(i * 4)..]).ShouldBe((uint)(((i + 1) << 24) | (i + 1)));
+ }
+
+ [Fact]
+ public void Read_bool_value()
+ {
+ var (type, _, data) = LogixServices.ParseReadReply(Rep("logix-read-bool.hex"));
+ type.ShouldBe(LogixTypeCode.Bool);
+ data.Length.ShouldBe(1);
+ data.Span[0].ShouldBe((byte)1);
+ }
+
+ // ab_server types STRING as atomic D0 00 (a real Logix STRING is A0 02 + handle): simulator behaviour.
+ [Fact]
+ public void Read_string_value_from_sim()
+ {
+ var (type, handle, data) = LogixServices.ParseReadReply(Rep("logix-read-string.hex"));
+ type.ShouldBe((ushort)0x00D0);
+ handle.ShouldBeNull();
+ data.Length.ShouldBe(88);
+ BitConverter.ToInt32(data.Span).ShouldBe(7);
+ Encoding.ASCII.GetString(data.Span.Slice(4, 7)).ShouldBe("HelloAB");
+ }
+
+ [Fact]
+ public void Read_unknown_tag_reply_status()
+ {
+ var r = Rep("logix-read-unknown-tag.hex");
+ r.GeneralStatus.ShouldBe((byte)0x04);
+ r.Status.ShouldBe(AbStatus.NotFound);
+ }
+
+ [Fact]
+ public void Write_request_matches_capture() =>
+ LogixServices.WriteTag(CipPath.EncodeSymbolic("TestDINT"), LogixTypeCode.DInt, null, 1, BitConverter.GetBytes(123456))
+ .ShouldBe(Req("logix-write-dint.hex", 1));
+
+ [Fact]
+ public void Write_reply_is_ok() => Rep("logix-write-dint.hex", 1).GeneralStatus.ShouldBe((byte)0);
+
+ [Fact]
+ public void Read_tag_plain_layout() =>
+ Convert.ToHexString(LogixServices.ReadTag(CipPath.EncodeSymbolic("Abc"), 3)).ShouldBe("4C03" + "910341626300" + "0300");
+
+ [Fact]
+ public void Fragmented_write_layout() =>
+ Convert.ToHexString(LogixServices.WriteTagFragmented(CipPath.EncodeSymbolic("Abc"), LogixTypeCode.DInt, null, 2, 8, [1, 2, 3, 4]))
+ .ShouldBe("5303" + "910341626300" + "C400" + "0200" + "08000000" + "01020304");
+
+ [Fact]
+ public void Struct_write_includes_handle() =>
+ Convert.ToHexString(LogixServices.WriteTag(CipPath.EncodeSymbolic("Abc"), LogixTypeCode.Struct, 0x0FCE, 1, [9]))
+ .ShouldBe("4D03" + "910341626300" + "A002" + "CE0F" + "0100" + "09");
+
+ [Fact]
+ public void Struct_write_without_handle_throws() =>
+ Should.Throw(() => LogixServices.WriteTag(CipPath.EncodeSymbolic("Abc"), LogixTypeCode.Struct, null, 1, [9]));
+
+ [Fact]
+ public void Read_reply_with_struct_handle()
+ {
+ var reply = CipReply.Parse(Convert.FromHexString("CC000000" + "A002" + "CE0F" + "05000000" + "48656C6C6F"));
+ var (type, handle, data) = LogixServices.ParseReadReply(reply);
+ type.ShouldBe((ushort)0x02A0); handle.ShouldBe((ushort)0x0FCE); data.Length.ShouldBe(9);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/SymbolObjectServicesTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/SymbolObjectServicesTests.cs
new file mode 100644
index 00000000..afb01c31
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/SymbolObjectServicesTests.cs
@@ -0,0 +1,63 @@
+using System.Buffers.Binary;
+using System.Text;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Logix;
+
+public sealed class SymbolObjectServicesTests
+{
+ private static readonly string[] ExpectedNames =
+ ["Motor_Run", "Line1_Count", "Pump_1_Speed", "TestArray", "TestString", "TestSINT", "TestBOOL", "TestREAL", "TestDINT"];
+
+ [Fact]
+ public void Request_matches_capture()
+ {
+ // libplctag requests attributes 2,7,8,1 in that order -- same as the brief; no change needed.
+ var cap = CaptureFile.CipPayload(CaptureFile.Load("logix-tags-listing.hex").First(f => f.FromClient));
+ SymbolObjectServices.GetInstanceAttributeList(null, 0).ShouldBe(cap);
+ }
+
+ [Fact]
+ public void Request_with_program_scope_and_start_instance() =>
+ Convert.ToHexString(SymbolObjectServices.GetInstanceAttributeList("Main", 0x0102)).ShouldBe(
+ "550A" + "910C" + Convert.ToHexString(Encoding.ASCII.GetBytes("Program:Main"))
+ + "206B2500" + "0201" + "0400" + "0200" + "0700" + "0800" + "0100");
+
+ [Fact]
+ public void Captured_reply_entries_have_the_decoder_layout()
+ {
+ var reply = CipReply.Parse(CaptureFile.CipPayload(CaptureFile.Load("logix-tags-listing.hex").First(f => !f.FromClient)));
+ var (entries, last, more) = SymbolObjectServices.ParseListingReply(reply);
+ more.ShouldBeFalse();
+ last.ShouldBe(9u);
+
+ var names = new List();
+ var pos = 0;
+ uint expectedInstance = 1;
+ while (pos < entries.Length)
+ {
+ BinaryPrimitives.ReadUInt32LittleEndian(entries.AsSpan(pos)).ShouldBe(expectedInstance++);
+ var nameLen = BinaryPrimitives.ReadUInt16LittleEndian(entries.AsSpan(pos + 20));
+ names.Add(Encoding.ASCII.GetString(entries, pos + 22, nameLen));
+ pos += 22 + nameLen;
+ }
+ names.ShouldBe(ExpectedNames);
+ }
+
+ [Fact]
+ public void More_flag_follows_status_0x06()
+ {
+ var entry = Convert.FromHexString("05000000" + "C400" + "0400" + "00000000" + "00000000" + "00000000" + "0100" + "41");
+ var reply = new CipReply(0x55, 0x06, [], entry);
+ var (entries, last, more) = SymbolObjectServices.ParseListingReply(reply);
+ more.ShouldBeTrue(); last.ShouldBe(5u); entries.ShouldBe(entry);
+ }
+
+ [Fact]
+ public void Failure_status_throws() =>
+ Should.Throw(() =>
+ SymbolObjectServices.ParseListingReply(new CipReply(0x55, 0x04, [], ReadOnlyMemory.Empty)));
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/TemplateObjectServicesTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/TemplateObjectServicesTests.cs
new file mode 100644
index 00000000..136af210
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/TemplateObjectServicesTests.cs
@@ -0,0 +1,53 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Logix;
+
+// No template exchange exists in the golden captures (ab_server has no Template Object), so these
+// are layout tests against the brief / CIP spec.
+public sealed class TemplateObjectServicesTests
+{
+ [Fact]
+ public void Get_attributes_request() =>
+ Convert.ToHexString(TemplateObjectServices.GetAttributes(0x0123)).ShouldBe(
+ "0303" + "206C2500" + "2301" + "0400" + "0400" + "0500" + "0200" + "0100");
+
+ [Fact]
+ public void Parse_attributes()
+ {
+ var reply = CipReply.Parse(Convert.FromHexString("83000000" + "0400"
+ + "0400" + "0000" + "2A000000"
+ + "0500" + "0000" + "18000000"
+ + "0200" + "0000" + "0300"
+ + "0100" + "0000" + "CE0F"));
+ var a = TemplateObjectServices.ParseAttributes(reply);
+ a.ShouldBe(new TemplateAttributes(42, 24, 3, 0x0FCE));
+ }
+
+ [Fact]
+ public void Parse_attributes_nonzero_attribute_status_throws()
+ {
+ var reply = CipReply.Parse(Convert.FromHexString("83000000" + "0100" + "0400" + "0100" + "2A000000"));
+ Should.Throw(() => TemplateObjectServices.ParseAttributes(reply));
+ }
+
+ [Fact]
+ public void Read_template_request() =>
+ Convert.ToHexString(TemplateObjectServices.ReadTemplate(0x0123, 0x20, 0x1F4)).ShouldBe(
+ "4C03" + "206C2500" + "2301" + "20000000" + "F401");
+
+ [Fact]
+ public void Read_more_follows_status_0x06()
+ {
+ TemplateObjectServices.ReadHasMore(new CipReply(0x4C, 0x06, [], ReadOnlyMemory.Empty)).ShouldBeTrue();
+ TemplateObjectServices.ReadHasMore(new CipReply(0x4C, 0x00, [], ReadOnlyMemory.Empty)).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Decoder_buffer_header()
+ {
+ var buf = TemplateObjectServices.BuildDecoderBuffer(0x0123, new TemplateAttributes(42, 24, 3, 0x0FCE), [0xAA, 0xBB]);
+ Convert.ToHexString(buf).ShouldBe("2301" + "2A000000" + "18000000" + "0300" + "CE0F" + "AABB");
+ }
+}