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..];
}
}