From 13d631d856a4ca7a125c50b16cd9c92ee19befe6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 7 Oct 2026 16:03:43 -0400 Subject: [PATCH] feat(ab-transport): PCCC Execute-PCCC codec for SLC/MicroLogix typed logical and PLC-5 range read --- .../Pccc/PcccAddress.cs | 101 ++++++++++ .../Pccc/PcccFileType.cs | 34 ++++ .../Pccc/PcccServices.cs | 97 ++++++++++ .../Pccc/PcccAddressTests.cs | 52 +++++ .../Pccc/PcccServicesTests.cs | 183 ++++++++++++++++++ 5 files changed, 467 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccAddress.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccFileType.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccServices.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccAddressTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccServicesTests.cs 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); + } +}