Files
Joseph Doherty 004c09fa13 fix(ablegacy): an Unsupported the device replied with is an answered refusal, not a local one
Review I2: Driver.Ab.Transport raises statuses that came off the wire in an
Execute PCCC reply (envelope CIP status, PCCC STS) as AbDeviceReplyException.
AbLegacy's IsLocalRefusal now excludes it, and the new IsAnsweredRefusal (used
by the read, write, probe and indirect-index paths) counts a wire Unsupported -
CIP 0x08/0x1C/0x20 on the envelope, STS 0x10 - as answered: per-tag
BadNotSupported, Healthy, LastSuccessfulRead advanced. Before, they landed in
the 'refused before the wire' neutral class. Pre-wire refusals are unchanged
(still neutral). A socket test covers all four codes (red before); transport
tests pin the marker and a pre-wire builder refusal as the positive control.
The AbCip Forward Open BadConnection path is untouched.

Refs #747
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 17:21:42 -04:00

246 lines
12 KiB
C#

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: golden STS replies (Lane K F3 / AbLegacy F8) ----
/// <summary>
/// Wire-level golden replies: the Execute PCCC reply bytes a device (or a bridge in front of it) sends for each
/// STS, through <see cref="PcccServices.ParseReply"/>, to the status the drivers classify.
/// </summary>
/// <param name="stsHex">The reply's STS (+ EXT STS) bytes as hex.</param>
/// <param name="expected">The status ParseReply raises.</param>
[Theory]
[InlineData("20", AbStatus.BadConnection)]
[InlineData("30", AbStatus.BadConnection)]
[InlineData("90", AbStatus.BadConnection)]
[InlineData("50", AbStatus.NotFound)]
[InlineData("60", AbStatus.NotAllowed)]
[InlineData("70", AbStatus.DeviceStateConflict)]
[InlineData("B0", AbStatus.DeviceStateConflict)]
[InlineData("F0|06", AbStatus.NotFound)]
[InlineData("F0|07", AbStatus.OutOfBounds)]
public void ParseReply_golden_sts(string stsHex, AbStatus expected)
{
var parts = stsHex.Split('|');
var hex = Id + "4F" + parts[0] + "9987" + (parts.Length > 1 ? parts[1] : "");
Should.Throw<AbTransportException>(() => PcccServices.ParseReply(Reply(hex), 0x8799))
.Status.ShouldBe(expected);
}
// ---- 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<AbDeviceReplyException>(() => PcccServices.ParseReply(Reply("", 0x08), 1)).Status.ShouldBe(AbStatus.Unsupported);
/// <summary>Review I2: a status the device REPLIED with is marked as such (envelope CIP status and PCCC STS).</summary>
[Fact]
public void ParseReply_marks_device_statuses_as_device_replies()
{
Should.Throw<AbDeviceReplyException>(() => PcccServices.ParseReply(Reply("", 0x20), 1)).Status.ShouldBe(AbStatus.Unsupported);
Should.Throw<AbDeviceReplyException>(() => PcccServices.ParseReply(Reply(Id + "4F10 9987"), 0x8799)).Status.ShouldBe(AbStatus.Unsupported);
}
/// <summary>Positive control: a refusal raised by a builder before anything is sent is NOT a device reply.</summary>
[Fact]
public void A_pre_wire_builder_refusal_is_not_a_device_reply()
{
var ex = Should.Throw<AbTransportException>(() => PcccServices.SlcTypedRead(1, A("N7:0"), 999));
ex.ShouldNotBeOfType<AbDeviceReplyException>();
}
[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
// 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);
}
}