fix(ab-transport): PCCC review r1 - UA bit, bit limits, stricter ParseReply, non-const verified flags
This commit is contained in:
@@ -40,6 +40,7 @@ public sealed record PcccAddress(string FileLetter, int? FileNumber, int Element
|
||||
("C", "DN") => (0, 13),
|
||||
("C", "OV") => (0, 12),
|
||||
("C", "UN") => (0, 11),
|
||||
("C", "UA") => (0, 10),
|
||||
("R", "EN") => (0, 15),
|
||||
("R", "EU") => (0, 14),
|
||||
("R", "DN") => (0, 13),
|
||||
@@ -94,6 +95,12 @@ public sealed record PcccAddress(string FileLetter, int? FileNumber, int Element
|
||||
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 (bit is int bb)
|
||||
{
|
||||
// Same limits as AbLegacyAddress: 16-bit words N/B/I/O/S/A 0-15, L 0-31; no bits on F/T/C/R/ST.
|
||||
var max = letter switch { "L" => 31, "N" or "B" or "I" or "O" or "S" or "A" => 15, _ => -1 };
|
||||
if (bb > max) 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; }
|
||||
|
||||
@@ -11,10 +11,10 @@ public static class PcccServices
|
||||
public const int MaxDataBytes = 236;
|
||||
|
||||
/// <summary>True: PLC-5 range READ (cmd 0x0F fnc 0x01) is capture-verified.</summary>
|
||||
public const bool Plc5Verified = true;
|
||||
public static bool Plc5Verified { get; } = true;
|
||||
|
||||
/// <summary>False: no PLC-5 write capture exists, so no PLC-5 write builder ships.</summary>
|
||||
public const bool Plc5WriteVerified = false;
|
||||
public static bool Plc5WriteVerified { get; } = 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) =>
|
||||
@@ -51,7 +51,8 @@ public static class PcccServices
|
||||
return w.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Protected typed logical write with 3 address fields (0x0F/0xAA).</summary>
|
||||
/// <summary>Protected typed logical write with 3 address fields (0x0F/0xAA). SLC/MicroLogix/LogixPccc
|
||||
/// only: it must not be used for PLC-5 (the family guard lives in the AbLegacy native runtime, Task 14).</summary>
|
||||
public static byte[] SlcTypedWrite(ushort tns, PcccAddress addr, ReadOnlySpan<byte> data)
|
||||
{
|
||||
CheckCount(data.Length);
|
||||
@@ -62,7 +63,8 @@ public static class PcccServices
|
||||
|
||||
/// <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>
|
||||
/// <c>totalTrans</c> counts 16-bit WORDS (DF1 word range read; the capture shows 1 for one 2-byte N element).
|
||||
/// Only N-file reads are capture-verified, other PLC-5 file types are unverified; sub-element addressing is not and throws Unsupported.</summary>
|
||||
public static byte[] Plc5RangeRead(ushort tns, PcccAddress addr, ushort totalTrans, int byteCount)
|
||||
{
|
||||
CheckCount(byteCount);
|
||||
@@ -75,23 +77,32 @@ public static class PcccServices
|
||||
}
|
||||
|
||||
/// <summary>Validates an Execute PCCC reply and returns the PCCC data bytes.</summary>
|
||||
public static ReadOnlyMemory<byte> ParseReply(CipReply reply, ushort expectedTns)
|
||||
/// <param name="reply">The parsed CIP reply.</param>
|
||||
/// <param name="expectedTns">The request's TNS; checked before STS so a stale error reply is BadReply.</param>
|
||||
/// <param name="expectedRequestorId">Optional full echoed requestor id (length byte included) to compare.</param>
|
||||
public static ReadOnlyMemory<byte> ParseReply(CipReply reply, ushort expectedTns, byte[]? expectedRequestorId = null)
|
||||
{
|
||||
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 idBody = r.Bytes(idLen - 1);
|
||||
if (expectedRequestorId is not null
|
||||
&& !(expectedRequestorId.Length == idLen && expectedRequestorId[0] == idLen
|
||||
&& idBody.SequenceEqual(expectedRequestorId.AsSpan(1))))
|
||||
throw new AbTransportException(AbStatus.BadReply, "PCCC reply requestor id does not match the request.");
|
||||
var cmd = r.U8();
|
||||
if (cmd != 0x4F)
|
||||
throw new AbTransportException(AbStatus.BadReply, $"PCCC reply command 0x{cmd:X2} != 0x4F.");
|
||||
var sts = r.U8();
|
||||
var tns = r.U16();
|
||||
if (tns != expectedTns)
|
||||
throw new AbTransportException(AbStatus.BadReply, $"PCCC TNS {tns} != {expectedTns}.");
|
||||
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..];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ public sealed class PcccAddressTests
|
||||
[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("C5:0.UA", "C", 5, 0, 0, 10)]
|
||||
[InlineData("L9:0/31", "L", 9, 0, null, 31)]
|
||||
[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)]
|
||||
@@ -39,6 +41,7 @@ public sealed class PcccAddressTests
|
||||
[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")]
|
||||
[InlineData("N7:0/16")] [InlineData("F8:0/1")] [InlineData("L9:0/32")] [InlineData("T4:0/3")]
|
||||
public void Rejects(string text) => PcccAddress.TryParse(text).ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
|
||||
+21
-1
@@ -122,7 +122,27 @@ public sealed class PcccServicesTests
|
||||
|
||||
[Fact]
|
||||
public void ParseReply_cip_error_status_throws() =>
|
||||
Should.Throw<AbTransportException>(() => PcccServices.ParseReply(Reply("", 0x08), 1)).Status.ShouldNotBe(AbStatus.Ok);
|
||||
Should.Throw<AbTransportException>(() => PcccServices.ParseReply(Reply("", 0x08), 1)).Status.ShouldBe(AbStatus.Unsupported);
|
||||
|
||||
[Fact]
|
||||
public void ParseReply_wrong_command_byte_is_BadReply() =>
|
||||
Should.Throw<AbTransportException>(() => 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<AbTransportException>(() => 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<AbTransportException>(() =>
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user