feat(ab-transport): PCCC Execute-PCCC codec for SLC/MicroLogix typed logical and PLC-5 range read
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 <tns> 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<AbTransportException>(() => 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<AbTransportException>(() => 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<AbTransportException>(() => 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<AbTransportException>(() => PcccServices.ParseReply(Reply(Id + "4F10 9987"), 0x8799))
|
||||
.Status.ShouldBe(AbStatus.Unsupported);
|
||||
|
||||
[Fact]
|
||||
public void ParseReply_F0_ext_06_is_NotFound() =>
|
||||
Should.Throw<AbTransportException>(() => PcccServices.ParseReply(Reply(Id + "4FF0 9987 06"), 0x8799))
|
||||
.Status.ShouldBe(AbStatus.NotFound);
|
||||
|
||||
[Fact]
|
||||
public void ParseReply_tns_mismatch_is_BadReply() =>
|
||||
Should.Throw<AbTransportException>(() => PcccServices.ParseReply(Reply(Id + "4F00 9987 E110"), 0x1234))
|
||||
.Status.ShouldBe(AbStatus.BadReply);
|
||||
|
||||
[Fact]
|
||||
public void ParseReply_truncated_is_BadReply() =>
|
||||
Should.Throw<AbTransportException>(() => PcccServices.ParseReply(Reply(Id + "4F00 99"), 0x8799))
|
||||
.Status.ShouldBe(AbStatus.BadReply);
|
||||
|
||||
[Fact]
|
||||
public void ParseReply_cip_error_status_throws() =>
|
||||
Should.Throw<AbTransportException>(() => 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user