diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccAddress.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccAddress.cs index f6c19d0f..427251e7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccAddress.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccAddress.cs @@ -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; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccServices.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccServices.cs index e847ef5f..7e1febc9 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccServices.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccServices.cs @@ -11,10 +11,10 @@ public static class PcccServices public const int MaxDataBytes = 236; /// True: PLC-5 range READ (cmd 0x0F fnc 0x01) is capture-verified. - public const bool Plc5Verified = true; + public static bool Plc5Verified { get; } = true; /// False: no PLC-5 write capture exists, so no PLC-5 write builder ships. - public const bool Plc5WriteVerified = false; + public static bool Plc5WriteVerified { get; } = false; /// Wraps a PCCC command in Execute PCCC: 4B 02 20 67 24 01 + requestor id (7, vendor, serial) + PCCC. public static byte[] ExecutePccc(ushort requestorVendor, uint requestorSerial, ReadOnlySpan pccc) => @@ -51,7 +51,8 @@ public static class PcccServices return w.ToArray(); } - /// Protected typed logical write with 3 address fields (0x0F/0xAA). + /// 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). public static byte[] SlcTypedWrite(ushort tns, PcccAddress addr, ReadOnlySpan data) { CheckCount(data.Length); @@ -62,7 +63,8 @@ public static class PcccServices /// PLC-5 range read (0x0F/0x01), as captured from libplctag: /// offset u16 (0), totalTrans u16, levelMask u8 (0x06 = file+element), file*, element*, byteCount u8. - /// Only N-file reads are capture-verified; sub-element addressing is not and throws Unsupported. + /// totalTrans 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. public static byte[] Plc5RangeRead(ushort tns, PcccAddress addr, ushort totalTrans, int byteCount) { CheckCount(byteCount); @@ -75,23 +77,32 @@ public static class PcccServices } /// Validates an Execute PCCC reply and returns the PCCC data bytes. - public static ReadOnlyMemory ParseReply(CipReply reply, ushort expectedTns) + /// The parsed CIP reply. + /// The request's TNS; checked before STS so a stale error reply is BadReply. + /// Optional full echoed requestor id (length byte included) to compare. + public static ReadOnlyMemory 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..]; } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccAddressTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccAddressTests.cs index 9ec77d19..a93a9be7 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccAddressTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccAddressTests.cs @@ -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] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccServicesTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccServicesTests.cs index 5431420b..b8d36f68 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccServicesTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccServicesTests.cs @@ -122,7 +122,27 @@ public sealed class PcccServicesTests [Fact] public void ParseReply_cip_error_status_throws() => - Should.Throw(() => PcccServices.ParseReply(Reply("", 0x08), 1)).Status.ShouldNotBe(AbStatus.Ok); + Should.Throw(() => PcccServices.ParseReply(Reply("", 0x08), 1)).Status.ShouldBe(AbStatus.Unsupported); + + [Fact] + public void ParseReply_wrong_command_byte_is_BadReply() => + Should.Throw(() => 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(() => 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(() => + 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