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,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..];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user