diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccAddress.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccAddress.cs
new file mode 100644
index 00000000..f6c19d0f
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccAddress.cs
@@ -0,0 +1,101 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc;
+
+/// Transport-side PCCC data-table address. Independent of the driver's AbLegacyAddress.
+/// File letter(s): N, F, B, T, C, R, ST, L, A, I, O, S.
+/// Explicit file number; null for O/I/S (implicit 0/1/2).
+/// Element (word) number.
+/// Word offset within a structured element (T/C: PRE=1, ACC=2; R: LEN=1, POS=2); null = 0.
+/// Bit within the addressed word (/n or a status-bit sub-element), else null.
+public sealed record PcccAddress(string FileLetter, int? FileNumber, int Element, int? SubElement, int? Bit = null)
+{
+ /// The PCCC file-type byte; throws Unsupported for an unknown letter.
+ public byte FileTypeCode =>
+ PcccFileType.CodeFor(FileLetter)
+ ?? throw new AbTransportException(AbStatus.Unsupported, $"Unknown PCCC file type '{FileLetter}'.");
+
+ /// Explicit file number, else the implicit one for O/I/S.
+ public int EffectiveFileNumber =>
+ FileNumber ?? PcccFileType.ImplicitFileNumber(FileLetter)
+ ?? throw new AbTransportException(AbStatus.Unsupported, $"File type '{FileLetter}' needs an explicit file number.");
+
+ /// The word offset to read within the element, and the bit within that word if any.
+ public (int Word, int? Bit) ResolveSubElement() => (SubElement ?? 0, Bit);
+
+ /// Builds an address from a sub-element mnemonic (PRE, ACC, LEN, POS, or a status bit such as DN).
+ public static PcccAddress WithSubElementName(string letter, int? fileNumber, int element, string subName)
+ {
+ var l = letter.ToUpperInvariant();
+ var s = subName.ToUpperInvariant();
+ (int Word, int? Bit)? r = (l, s) switch
+ {
+ ("T" or "C", "PRE") => (1, null),
+ ("T" or "C", "ACC") => (2, null),
+ ("R", "LEN") => (1, null),
+ ("R", "POS") => (2, null),
+ ("T", "EN") => (0, 15),
+ ("T", "TT") => (0, 14),
+ ("T", "DN") => (0, 13),
+ ("C", "CU") => (0, 15),
+ ("C", "CD") => (0, 14),
+ ("C", "DN") => (0, 13),
+ ("C", "OV") => (0, 12),
+ ("C", "UN") => (0, 11),
+ ("R", "EN") => (0, 15),
+ ("R", "EU") => (0, 14),
+ ("R", "DN") => (0, 13),
+ ("R", "EM") => (0, 12),
+ ("R", "ER") => (0, 11),
+ ("R", "UL") => (0, 10),
+ ("R", "IN") => (0, 9),
+ ("R", "FD") => (0, 8),
+ _ => null,
+ };
+ if (r is not { } v)
+ throw new AbTransportException(AbStatus.Unsupported, $"Unknown sub-element '{subName}' for file type '{letter}'.");
+ return new PcccAddress(l, fileNumber, element, v.Word, v.Bit);
+ }
+
+ /// Parses N7:0, B3:1/4, T4:0.ACC, I:0/1, ST9:2; null when malformed.
+ public static PcccAddress? TryParse(string? text)
+ {
+ if (string.IsNullOrWhiteSpace(text)) return null;
+ var src = text.Trim();
+ int? bit = null;
+ var slash = src.IndexOf('/');
+ if (slash >= 0)
+ {
+ if (!int.TryParse(src[(slash + 1)..], out var b) || b < 0) return null;
+ bit = b;
+ src = src[..slash];
+ }
+ string? sub = null;
+ var dot = src.LastIndexOf('.');
+ if (dot >= 0)
+ {
+ sub = src[(dot + 1)..];
+ if (sub.Length == 0 || !sub.All(char.IsLetter)) return null;
+ src = src[..dot];
+ }
+ var colon = src.IndexOf(':');
+ if (colon <= 0 || !int.TryParse(src[(colon + 1)..], out var element) || element < 0) return null;
+ var filePart = src[..colon];
+ if (!char.IsLetter(filePart[0])) return null;
+ var end = 1;
+ while (end < filePart.Length && char.IsLetter(filePart[end])) end++;
+ var letter = filePart[..end].ToUpperInvariant();
+ if (PcccFileType.CodeFor(letter) is null) return null;
+ int? file = null;
+ if (end < filePart.Length)
+ {
+ if (!int.TryParse(filePart[end..], out var fn) || fn < 0) return null;
+ file = fn;
+ }
+ var implicitFile = PcccFileType.ImplicitFileNumber(letter);
+ if (implicitFile is not null && file is not null) return null;
+ if (implicitFile is null && file is null) return null;
+ if (sub is not null && bit is not null) return null;
+ if (sub is null) return new PcccAddress(letter, file, element, null, bit);
+ try { return WithSubElementName(letter, file, element, sub); }
+ catch (AbTransportException) { return null; }
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccFileType.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccFileType.cs
new file mode 100644
index 00000000..c39684ab
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccFileType.cs
@@ -0,0 +1,34 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc;
+
+/// PCCC file-type codes for the protected typed logical read/write commands (0xA2/0xAA).
+/// N (0x89) and F (0x8A) are verified against the golden captures; the rest follow the PCCC
+/// data-table file-type table.
+public static class PcccFileType
+{
+ /// Returns the file-type code for a file letter (case-insensitive), or null when unknown.
+ public static byte? CodeFor(string fileLetter) => fileLetter.ToUpperInvariant() switch
+ {
+ "O" => 0x8B,
+ "I" => 0x8C,
+ "S" => 0x84,
+ "B" => 0x85,
+ "T" => 0x86,
+ "C" => 0x87,
+ "R" => 0x88,
+ "N" => 0x89,
+ "F" => 0x8A,
+ "ST" => 0x8D,
+ "A" => 0x8E,
+ "L" => 0x91,
+ _ => null,
+ };
+
+ /// The implicit file number for the single-letter system files (O=0, I=1, S=2), else null.
+ public static int? ImplicitFileNumber(string fileLetter) => fileLetter.ToUpperInvariant() switch
+ {
+ "O" => 0,
+ "I" => 1,
+ "S" => 2,
+ _ => null,
+ };
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccServices.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccServices.cs
new file mode 100644
index 00000000..e847ef5f
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccServices.cs
@@ -0,0 +1,97 @@
+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.Pccc;
+
+/// Pure builders/parsers for Execute PCCC (CIP 0x4B on class 0x67) carrying SLC/MicroLogix
+/// protected typed logical commands and PLC-5 range reads. Framing is taken from the golden captures.
+/// The Execute PCCC request is sent bare (no Unconnected Send, no route) per the captures.
+public static class PcccServices
+{
+ /// Largest data byte count one typed read/write may carry (single-byte count on the wire).
+ public const int MaxDataBytes = 236;
+
+ /// True: PLC-5 range READ (cmd 0x0F fnc 0x01) is capture-verified.
+ public const bool Plc5Verified = true;
+
+ /// False: no PLC-5 write capture exists, so no PLC-5 write builder ships.
+ public const bool Plc5WriteVerified = false;
+
+ /// Wraps a PCCC command in Execute PCCC: 4B 02 20 67 24 01 + requestor id (7, vendor, serial) + PCCC.
+ public static byte[] ExecutePccc(ushort requestorVendor, uint requestorSerial, ReadOnlySpan pccc) =>
+ new CipWriter().U8(0x4B).U8(2).U8(0x20).U8(0x67).U8(0x24).U8(0x01)
+ .U8(7).U16(requestorVendor).U32(requestorSerial).Bytes(pccc).ToArray();
+
+ private static void Field(CipWriter w, int v)
+ {
+ if (v < 0) throw new AbTransportException(AbStatus.OutOfBounds, $"Negative PCCC field {v}.");
+ if (v > ushort.MaxValue) throw new AbTransportException(AbStatus.OutOfBounds, $"PCCC field {v} exceeds 65535.");
+ if (v < 0xFF) w.U8((byte)v); else w.U8(0xFF).U16((ushort)v);
+ }
+
+ private static void CheckCount(int n)
+ {
+ if (n < 0 || n > MaxDataBytes)
+ throw new AbTransportException(AbStatus.TooLarge, $"PCCC byte count {n} exceeds {MaxDataBytes}.");
+ }
+
+ private static void Logical(CipWriter w, PcccAddress a)
+ {
+ Field(w, a.EffectiveFileNumber);
+ w.U8(a.FileTypeCode);
+ Field(w, a.Element);
+ Field(w, a.ResolveSubElement().Word);
+ }
+
+ /// Protected typed logical read with 3 address fields (0x0F/0xA2).
+ public static byte[] SlcTypedRead(ushort tns, PcccAddress addr, int byteCount)
+ {
+ CheckCount(byteCount);
+ var w = new CipWriter().U8(0x0F).U8(0).U16(tns).U8(0xA2).U8((byte)byteCount);
+ Logical(w, addr);
+ return w.ToArray();
+ }
+
+ /// Protected typed logical write with 3 address fields (0x0F/0xAA).
+ public static byte[] SlcTypedWrite(ushort tns, PcccAddress addr, ReadOnlySpan data)
+ {
+ CheckCount(data.Length);
+ var w = new CipWriter().U8(0x0F).U8(0).U16(tns).U8(0xAA).U8((byte)data.Length);
+ Logical(w, addr);
+ return w.Bytes(data).ToArray();
+ }
+
+ /// PLC-5 range read (0x0F/0x01), as captured from libplctag:
+ /// offset u16 (0), totalTrans u16, levelMask u8 (0x06 = file+element), file*, element*, byteCount u8.
+ /// Only N-file reads are capture-verified; sub-element addressing is not and throws Unsupported.
+ public static byte[] Plc5RangeRead(ushort tns, PcccAddress addr, ushort totalTrans, int byteCount)
+ {
+ CheckCount(byteCount);
+ if (addr.ResolveSubElement().Word != 0)
+ throw new AbTransportException(AbStatus.Unsupported, "PLC-5 sub-element addressing is not capture-verified.");
+ var w = new CipWriter().U8(0x0F).U8(0).U16(tns).U8(0x01).U16(0).U16(totalTrans).U8(0x06);
+ Field(w, addr.EffectiveFileNumber);
+ Field(w, addr.Element);
+ return w.U8((byte)byteCount).ToArray();
+ }
+
+ /// Validates an Execute PCCC reply and returns the PCCC data bytes.
+ public static ReadOnlyMemory ParseReply(CipReply reply, ushort expectedTns)
+ {
+ if (reply.GeneralStatus != 0)
+ throw new AbTransportException(reply.Status, $"Execute PCCC CIP status 0x{reply.GeneralStatus:X2}.");
+ var r = new CipReader(reply.Data.Span);
+ var idLen = r.U8();
+ if (idLen < 1) throw new AbTransportException(AbStatus.BadReply, "Empty requestor id in PCCC reply.");
+ r.Bytes(idLen - 1);
+ r.U8(); // command | 0x40
+ var sts = r.U8();
+ var tns = r.U16();
+ byte? ext = sts == 0xF0 ? r.U8() : null;
+ if (sts != 0)
+ throw new AbTransportException(AbStatusCodes.FromPccc(sts, ext),
+ $"PCCC STS 0x{sts:X2}{(ext is { } e ? $" EXT 0x{e:X2}" : "")}.");
+ if (tns != expectedTns)
+ throw new AbTransportException(AbStatus.BadReply, $"PCCC TNS {tns} != {expectedTns}.");
+ return reply.Data[r.Position..];
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccAddressTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccAddressTests.cs
new file mode 100644
index 00000000..9ec77d19
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccAddressTests.cs
@@ -0,0 +1,52 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Pccc;
+
+public sealed class PcccAddressTests
+{
+ [Theory]
+ [InlineData("O", 0x8B)] [InlineData("I", 0x8C)] [InlineData("S", 0x84)] [InlineData("B", 0x85)]
+ [InlineData("T", 0x86)] [InlineData("C", 0x87)] [InlineData("R", 0x88)] [InlineData("N", 0x89)]
+ [InlineData("F", 0x8A)] [InlineData("ST", 0x8D)] [InlineData("A", 0x8E)] [InlineData("L", 0x91)]
+ public void File_type_codes(string letter, int code) => PcccFileType.CodeFor(letter).ShouldBe((byte)code);
+
+ [Theory]
+ [InlineData("N7:0", "N", 7, 0, null, null)]
+ [InlineData("F8:12", "F", 8, 12, null, null)]
+ [InlineData("B3:1/4", "B", 3, 1, null, 4)]
+ [InlineData("ST9:2", "ST", 9, 2, null, null)]
+ [InlineData("T4:0.ACC", "T", 4, 0, 2, null)]
+ [InlineData("T4:1.PRE", "T", 4, 1, 1, null)]
+ [InlineData("T4:0.DN", "T", 4, 0, 0, 13)]
+ [InlineData("T4:0.EN", "T", 4, 0, 0, 15)]
+ [InlineData("C5:0.CU", "C", 5, 0, 0, 15)]
+ [InlineData("C5:0.OV", "C", 5, 0, 0, 12)]
+ [InlineData("R6:0.LEN", "R", 6, 0, 1, null)]
+ [InlineData("R6:0.POS", "R", 6, 0, 2, null)]
+ [InlineData("I:0/1", "I", null, 0, null, 1)]
+ [InlineData("n7:0", "N", 7, 0, null, null)]
+ public void Parses(string text, string letter, int? file, int element, int? sub, int? bit)
+ {
+ var a = PcccAddress.TryParse(text)!;
+ a.ShouldNotBeNull();
+ a.FileLetter.ShouldBe(letter);
+ a.FileNumber.ShouldBe(file);
+ a.Element.ShouldBe(element);
+ a.ResolveSubElement().ShouldBe((sub ?? 0, bit));
+ }
+
+ [Theory]
+ [InlineData("")] [InlineData("N7")] [InlineData("N:0")] [InlineData("X7:0")] [InlineData("I1:0")]
+ [InlineData("T4:0.FOO")] [InlineData("N7:0.ACC")] [InlineData("N7:-1")] [InlineData("N7:0/x")]
+ public void Rejects(string text) => PcccAddress.TryParse(text).ShouldBeNull();
+
+ [Fact]
+ public void Effective_file_number_is_implicit_for_IOS()
+ {
+ PcccAddress.TryParse("O:0")!.EffectiveFileNumber.ShouldBe(0);
+ PcccAddress.TryParse("I:0")!.EffectiveFileNumber.ShouldBe(1);
+ PcccAddress.TryParse("S:0")!.EffectiveFileNumber.ShouldBe(2);
+ PcccAddress.TryParse("N9:0")!.EffectiveFileNumber.ShouldBe(9);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccServicesTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccServicesTests.cs
new file mode 100644
index 00000000..5431420b
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccServicesTests.cs
@@ -0,0 +1,183 @@
+using System.Buffers.Binary;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Pccc;
+
+// ---- Step 1: hand-decode of the golden captures (the capture is the spec) ----
+//
+// slc500-read-n7.hex, request frame (SendRRData 0x6F, one CPF item 0xB2 = unconnected data):
+// 4B 02 20 67 24 01 Execute PCCC, path size 2 words: class 0x67 inst 1 -- SENT BARE: no Unconnected
+// Send (0x52) wrapper, no route bytes, not connected (no Forward Open)
+// 07 3D F3 45 43 50 21 requestor id: length 7, vendor 0xF33D (LE 3D F3), serial 0x21504345
+// 0F 00 99 87 A2 02 07 89 00 00 PCCC: cmd 0x0F, sts 0, TNS 0x8799, fnc 0xA2 (typed logical read),
+// byteCount 2, file 7, type 0x89 (N), element 0, sub-element 0
+// reply item: CB 00 00 00 (service 0x4B|0x80, status 0, no ext) | 07 3D F3 45 43 50 21 |
+// 4F 00 99 87 (cmd 0x4F, sts 0, same TNS) | E1 10 -> N7:0 = 0x10E1 = 4321
+// slc500-read-f8: ... A2 04 08 8A 00 00 (4 bytes, file 8, type 0x8A F) ; reply data 00 00 60 40 = 3.5f
+// micrologix-read-n7: identical request shape; reply data A4 09 = 2468
+// slc500-write-n7: 0F 00 tns AA 02 07 89 00 00 E1 10 (fnc 0xAA, data appended); reply has no data
+// plc5-read-n7: PLC-5 does NOT use 0xA2/0x68. Captured PCCC = 0F 00 01 | 00 00 | 01 00 | 06 07 00 | 02 :
+// fnc 0x01 (range read), packet offset u16 0, total-trans u16 1, level mask 0x06 (levels file+element),
+// file 7, element 0, then a single byte count 2. No file-type byte. Reply data 4D 05 = 1357.
+// (The brief's fnc 0x68 / size-u16 shape is NOT what libplctag sends; the capture wins.)
+public sealed class PcccServicesTests
+{
+ private const ushort Vendor = 0xF33D;
+ private const uint Serial = 0x21504345;
+
+ private static byte[] Hex(string h) => Convert.FromHexString(h.Replace(" ", ""));
+ private static PcccAddress A(string s) => PcccAddress.TryParse(s)!;
+
+ [Fact]
+ public void ExecutePccc_wraps_with_requestor_id() =>
+ Convert.ToHexString(PcccServices.ExecutePccc(Vendor, Serial, [0xAA, 0xBB]))
+ .ShouldBe("4B0220672401" + "073DF3454350" + "21" + "AABB");
+
+ [Fact]
+ public void SlcTypedRead_N7_0() =>
+ PcccServices.SlcTypedRead(0x8799, A("N7:0"), 2).ShouldBe(Hex("0F00 9987 A2 02 07 89 00 00"));
+
+ [Fact]
+ public void SlcTypedRead_F8_3() =>
+ PcccServices.SlcTypedRead(0x0102, A("F8:3"), 4).ShouldBe(Hex("0F00 0201 A2 04 08 8A 03 00"));
+
+ [Fact]
+ public void SlcTypedWrite_N7_0() =>
+ PcccServices.SlcTypedWrite(0x8799, A("N7:0"), [0x2A, 0x00]).ShouldBe(Hex("0F00 9987 AA 02 07 89 00 00 2A00"));
+
+ [Fact]
+ public void Element_300_uses_FF_escape() =>
+ PcccServices.SlcTypedRead(1, A("N7:300"), 2).ShouldBe(Hex("0F00 0100 A2 02 07 89 FF2C01 00"));
+
+ [Fact]
+ public void File_300_uses_FF_escape() =>
+ PcccServices.SlcTypedRead(1, A("N300:0"), 2).ShouldBe(Hex("0F00 0100 A2 02 FF2C01 89 00 00"));
+
+ [Fact]
+ public void Value_255_uses_FF_escape_boundary() =>
+ PcccServices.SlcTypedRead(1, A("N7:255"), 2).ShouldBe(Hex("0F00 0100 A2 02 07 89 FFFF00 00"));
+
+ [Fact]
+ public void Timer_ACC_sub_element_is_word_2() =>
+ PcccServices.SlcTypedRead(1, A("T4:0.ACC"), 2).ShouldBe(Hex("0F00 0100 A2 02 04 86 00 02"));
+
+ [Fact]
+ public void Implicit_file_numbers_for_IOS()
+ {
+ PcccServices.SlcTypedRead(1, A("O:0"), 2).ShouldBe(Hex("0F00 0100 A2 02 00 8B 00 00"));
+ PcccServices.SlcTypedRead(1, A("I:1"), 2).ShouldBe(Hex("0F00 0100 A2 02 01 8C 01 00"));
+ PcccServices.SlcTypedRead(1, A("S:3"), 2).ShouldBe(Hex("0F00 0100 A2 02 02 84 03 00"));
+ }
+
+ [Fact]
+ public void ByteCount_236_ok_237_throws()
+ {
+ PcccServices.SlcTypedRead(1, A("N7:0"), 236)[5].ShouldBe((byte)236);
+ Should.Throw(() => PcccServices.SlcTypedRead(1, A("N7:0"), 237)).Status.ShouldBe(AbStatus.TooLarge);
+ PcccServices.SlcTypedWrite(1, A("N7:0"), new byte[236]).Length.ShouldBe(10 + 236); // 0F 00 tns2 fnc count file type elem sub + data
+ Should.Throw(() => PcccServices.SlcTypedWrite(1, A("N7:0"), new byte[237])).Status.ShouldBe(AbStatus.TooLarge);
+ PcccServices.Plc5RangeRead(1, A("N7:0"), 1, 236)[^1].ShouldBe((byte)236);
+ Should.Throw(() => PcccServices.Plc5RangeRead(1, A("N7:0"), 1, 237)).Status.ShouldBe(AbStatus.TooLarge);
+ }
+
+ [Fact]
+ public void Plc5RangeRead_N7_0_matches_captured_shape() =>
+ PcccServices.Plc5RangeRead(0xC473, A("N7:0"), 1, 2).ShouldBe(Hex("0F00 73C4 01 0000 0100 06 07 00 02"));
+
+ [Fact]
+ public void Plc5_write_is_not_shipped_and_flagged() => PcccServices.Plc5WriteVerified.ShouldBeFalse();
+
+ // ---- ParseReply ----
+ private static CipReply Reply(string hex, byte general = 0) =>
+ new(0x4B, general, [], Hex(hex));
+
+ private const string Id = "073DF3454350" + "21";
+
+ [Fact]
+ public void ParseReply_success_returns_data() =>
+ PcccServices.ParseReply(Reply(Id + "4F00 9987 E110"), 0x8799).ToArray().ShouldBe(Hex("E110"));
+
+ [Fact]
+ public void ParseReply_sts_0x10_is_Unsupported() =>
+ Should.Throw(() => PcccServices.ParseReply(Reply(Id + "4F10 9987"), 0x8799))
+ .Status.ShouldBe(AbStatus.Unsupported);
+
+ [Fact]
+ public void ParseReply_F0_ext_06_is_NotFound() =>
+ Should.Throw(() => PcccServices.ParseReply(Reply(Id + "4FF0 9987 06"), 0x8799))
+ .Status.ShouldBe(AbStatus.NotFound);
+
+ [Fact]
+ public void ParseReply_tns_mismatch_is_BadReply() =>
+ Should.Throw(() => PcccServices.ParseReply(Reply(Id + "4F00 9987 E110"), 0x1234))
+ .Status.ShouldBe(AbStatus.BadReply);
+
+ [Fact]
+ public void ParseReply_truncated_is_BadReply() =>
+ Should.Throw(() => PcccServices.ParseReply(Reply(Id + "4F00 99"), 0x8799))
+ .Status.ShouldBe(AbStatus.BadReply);
+
+ [Fact]
+ public void ParseReply_cip_error_status_throws() =>
+ Should.Throw(() => PcccServices.ParseReply(Reply("", 0x08), 1)).Status.ShouldNotBe(AbStatus.Ok);
+
+ // ---- Capture parity ----
+ // The request is frame index 2 (after RegisterSession pair); its TNS is session-assigned, so we read it
+ // out of the capture and rebuild with that TNS (TNS is the only normalized field). Vendor/serial come
+ // from the captured requestor id.
+ private static (byte[] Request, byte[] Reply, ushort Tns) Exchange(string file, int requestIndex = 2)
+ {
+ var frames = CaptureFile.Load(file);
+ var req = CaptureFile.CipPayload(frames[requestIndex]);
+ var rep = CaptureFile.CipPayload(frames[requestIndex + 1]);
+ // 6 bytes of 4B-header + 7 requestor id + 0F 00 -> TNS at offset 15.
+ var tns = BinaryPrimitives.ReadUInt16LittleEndian(req.AsSpan(15, 2));
+ return (req, rep, tns);
+ }
+
+ private static byte[] Wrap(byte[] pccc) => PcccServices.ExecutePccc(Vendor, Serial, pccc);
+
+ [Theory]
+ [InlineData("slc500-read-n7.hex", "N7:0", 2, 4321)]
+ [InlineData("micrologix-read-n7.hex", "N7:0", 2, 2468)]
+ public void Capture_read_n7_request_parity_and_value(string file, string addr, int bytes, int expected)
+ {
+ var (req, rep, tns) = Exchange(file);
+ Wrap(PcccServices.SlcTypedRead(tns, A(addr), bytes)).ShouldBe(req);
+ var data = PcccServices.ParseReply(CipReply.Parse(rep), tns).ToArray();
+ data.Length.ShouldBe(2);
+ BinaryPrimitives.ReadInt16LittleEndian(data).ShouldBe((short)expected);
+ }
+
+ [Fact]
+ public void Capture_slc500_read_f8_request_parity_and_value()
+ {
+ var (req, rep, tns) = Exchange("slc500-read-f8.hex");
+ Wrap(PcccServices.SlcTypedRead(tns, A("F8:0"), 4)).ShouldBe(req);
+ var data = PcccServices.ParseReply(CipReply.Parse(rep), tns).ToArray();
+ data.Length.ShouldBe(4);
+ BinaryPrimitives.ReadSingleLittleEndian(data).ShouldBe(3.5f);
+ }
+
+ [Fact]
+ public void Capture_plc5_read_n7_request_parity_and_value()
+ {
+ var (req, rep, tns) = Exchange("plc5-read-n7.hex");
+ Wrap(PcccServices.Plc5RangeRead(tns, A("N7:0"), 1, 2)).ShouldBe(req);
+ var data = PcccServices.ParseReply(CipReply.Parse(rep), tns).ToArray();
+ data.Length.ShouldBe(2);
+ BinaryPrimitives.ReadInt16LittleEndian(data).ShouldBe((short)1357);
+ }
+
+ [Fact]
+ public void Capture_slc500_write_n7_request_parity_and_empty_reply()
+ {
+ // frame 2 = initialize read, frame 4 = write of 4321
+ var (req, rep, tns) = Exchange("slc500-write-n7.hex", 4);
+ Wrap(PcccServices.SlcTypedWrite(tns, A("N7:0"), [0xE1, 0x10])).ShouldBe(req);
+ PcccServices.ParseReply(CipReply.Parse(rep), tns).Length.ShouldBe(0);
+ }
+}