feat(ab-transport): PCCC Execute-PCCC codec for SLC/MicroLogix typed logical and PLC-5 range read

This commit is contained in:
Joseph Doherty
2026-10-07 16:03:43 -04:00
parent ec1a39ff6e
commit 13d631d856
5 changed files with 467 additions and 0 deletions
@@ -0,0 +1,101 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc;
/// <summary>Transport-side PCCC data-table address. Independent of the driver's AbLegacyAddress.</summary>
/// <param name="FileLetter">File letter(s): N, F, B, T, C, R, ST, L, A, I, O, S.</param>
/// <param name="FileNumber">Explicit file number; null for O/I/S (implicit 0/1/2).</param>
/// <param name="Element">Element (word) number.</param>
/// <param name="SubElement">Word offset within a structured element (T/C: PRE=1, ACC=2; R: LEN=1, POS=2); null = 0.</param>
/// <param name="Bit">Bit within the addressed word (<c>/n</c> or a status-bit sub-element), else null.</param>
public sealed record PcccAddress(string FileLetter, int? FileNumber, int Element, int? SubElement, int? Bit = null)
{
/// <summary>The PCCC file-type byte; throws Unsupported for an unknown letter.</summary>
public byte FileTypeCode =>
PcccFileType.CodeFor(FileLetter)
?? throw new AbTransportException(AbStatus.Unsupported, $"Unknown PCCC file type '{FileLetter}'.");
/// <summary>Explicit file number, else the implicit one for O/I/S.</summary>
public int EffectiveFileNumber =>
FileNumber ?? PcccFileType.ImplicitFileNumber(FileLetter)
?? throw new AbTransportException(AbStatus.Unsupported, $"File type '{FileLetter}' needs an explicit file number.");
/// <summary>The word offset to read within the element, and the bit within that word if any.</summary>
public (int Word, int? Bit) ResolveSubElement() => (SubElement ?? 0, Bit);
/// <summary>Builds an address from a sub-element mnemonic (PRE, ACC, LEN, POS, or a status bit such as DN).</summary>
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);
}
/// <summary>Parses <c>N7:0</c>, <c>B3:1/4</c>, <c>T4:0.ACC</c>, <c>I:0/1</c>, <c>ST9:2</c>; null when malformed.</summary>
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; }
}
}
@@ -0,0 +1,34 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc;
/// <summary>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.</summary>
public static class PcccFileType
{
/// <summary>Returns the file-type code for a file letter (case-insensitive), or null when unknown.</summary>
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,
};
/// <summary>The implicit file number for the single-letter system files (O=0, I=1, S=2), else null.</summary>
public static int? ImplicitFileNumber(string fileLetter) => fileLetter.ToUpperInvariant() switch
{
"O" => 0,
"I" => 1,
"S" => 2,
_ => null,
};
}
@@ -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;
/// <summary>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.</summary>
public static class PcccServices
{
/// <summary>Largest data byte count one typed read/write may carry (single-byte count on the wire).</summary>
public const int MaxDataBytes = 236;
/// <summary>True: PLC-5 range READ (cmd 0x0F fnc 0x01) is capture-verified.</summary>
public const bool Plc5Verified = true;
/// <summary>False: no PLC-5 write capture exists, so no PLC-5 write builder ships.</summary>
public const bool Plc5WriteVerified = false;
/// <summary>Wraps a PCCC command in Execute PCCC: <c>4B 02 20 67 24 01</c> + requestor id (7, vendor, serial) + PCCC.</summary>
public static byte[] ExecutePccc(ushort requestorVendor, uint requestorSerial, ReadOnlySpan<byte> 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);
}
/// <summary>Protected typed logical read with 3 address fields (0x0F/0xA2).</summary>
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();
}
/// <summary>Protected typed logical write with 3 address fields (0x0F/0xAA).</summary>
public static byte[] SlcTypedWrite(ushort tns, PcccAddress addr, ReadOnlySpan<byte> 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();
}
/// <summary>PLC-5 range read (0x0F/0x01), as captured from libplctag:
/// <c>offset u16 (0), totalTrans u16, levelMask u8 (0x06 = file+element), file*, element*, byteCount u8</c>.
/// Only N-file reads are capture-verified; sub-element addressing is not and throws Unsupported.</summary>
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();
}
/// <summary>Validates an Execute PCCC reply and returns the PCCC data bytes.</summary>
public static ReadOnlyMemory<byte> 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..];
}
}
@@ -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);
}
}