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.ShouldBe(AbStatus.Unsupported); [Fact] public void ParseReply_wrong_command_byte_is_BadReply() => Should.Throw(() => PcccServices.ParseReply(Reply(Id + "0F00 9987 E110"), 0x8799)) .Status.ShouldBe(AbStatus.BadReply); [Fact] public void ParseReply_stale_error_reply_with_wrong_tns_is_BadReply() => Should.Throw(() => PcccServices.ParseReply(Reply(Id + "4F10 1111"), 0x8799)) .Status.ShouldBe(AbStatus.BadReply); [Fact] public void ParseReply_requestor_id_mismatch_is_BadReply() { var expected = Hex(Id); PcccServices.ParseReply(Reply(Id + "4F00 9987 E110"), 0x8799, expected).Length.ShouldBe(2); Should.Throw(() => PcccServices.ParseReply(Reply("073DF3454350" + "22" + "4F00 9987 E110"), 0x8799, expected)) .Status.ShouldBe(AbStatus.BadReply); } // ---- 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); } }