fix(ab-transport): map CIP 0x1C/0x20/0xFF-0x2104 as refusals; remap the PCCC STS table

K-F1 (AbCip #737 F1): FromCip maps general 0x1C and 0x20 to Unsupported and
0xFF/0x2104 to OutOfBounds, so a controller refusal is per-tag Bad instead of a
BadReply comms fault. A Forward Open refused with one of them is still
BadConnection. Every CIP code is now listed with its meaning; 0x01/0x07 stay
opaque on purpose.

K-F3 (AbLegacy F8, review 07 r0 I4): FromPccc follows the DF1 STS table:
0x20/0x30/0x90 -> BadConnection (a bridge reporting an unreachable END node),
0x50 and EXT 0x06 -> NotFound, EXT 0x07 -> OutOfBounds, 0x60 -> NotAllowed,
0x70/0xB0 -> DeviceStateConflict. Golden ParseReply tests per STS, and an
end-to-end AbLegacy test: STS 0x30 degrades with LastSuccessfulRead preserved;
the 0x60 positive control stays Healthy and advances it. Both were red before.
Driver edits are doc remarks and the two runtime tests the old table pinned.

Refs #747
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 16:42:57 -04:00
parent f9fbd4ee0d
commit dfc62cef14
10 changed files with 278 additions and 36 deletions
@@ -21,10 +21,15 @@ public sealed class AbStatusCodesTests
[InlineData((byte)0x0E, AbStatus.NotAllowed)]
[InlineData((byte)0x1E, AbStatus.BadReply)]
[InlineData((byte)0x01, AbStatus.BadReply)]
[InlineData((byte)0x07, AbStatus.BadReply)] // connection lost: opaque on purpose (AbLegacy #738)
// Lane K F1 (#737): the controller answered and refused this request — not a garbled reply.
[InlineData((byte)0x1C, AbStatus.Unsupported)] // missing attribute list entry data
[InlineData((byte)0x20, AbStatus.Unsupported)] // invalid parameter
public void Cip_general_status_maps(byte general, AbStatus expected) =>
AbStatusCodes.FromCip(general, ReadOnlySpan<ushort>.Empty).ShouldBe(expected);
[Theory]
[InlineData((ushort)0x2104, AbStatus.OutOfBounds)] // offset beyond end of the tag (Lane K F1)
[InlineData((ushort)0x2105, AbStatus.OutOfBounds)]
[InlineData((ushort)0x2107, AbStatus.TypeMismatch)]
[InlineData((ushort)0x9999, AbStatus.BadReply)]
@@ -37,16 +42,42 @@ public sealed class AbStatusCodesTests
[Theory]
[InlineData((byte)0x00, null, AbStatus.Ok)]
[InlineData((byte)0x10, null, AbStatus.Unsupported)]
[InlineData((byte)0x20, null, AbStatus.NotFound)]
[InlineData((byte)0x30, null, AbStatus.NotAllowed)]
[InlineData((byte)0x40, null, AbStatus.DeviceStateConflict)]
[InlineData((byte)0xF0, (byte)0x06, AbStatus.NotFound)]
[InlineData((byte)0xF0, (byte)0x07, AbStatus.NotFound)]
[InlineData((byte)0x10, null, AbStatus.Unsupported)] // illegal command or format
// Lane K F3 (AbLegacy F8, review 07 r0 I4), per the DF1/PCCC STS table (1770-6.5.16):
[InlineData((byte)0x20, null, AbStatus.BadConnection)] // host has a problem and will not communicate
[InlineData((byte)0x30, null, AbStatus.BadConnection)] // remote node host missing / disconnected / shut down
[InlineData((byte)0x40, null, AbStatus.DeviceStateConflict)] // host hardware fault (the host answered)
[InlineData((byte)0x50, null, AbStatus.NotFound)] // addressing problem or memory protect rungs
[InlineData((byte)0x60, null, AbStatus.NotAllowed)] // function not allowed due to command protection
[InlineData((byte)0x70, null, AbStatus.DeviceStateConflict)] // processor is in Program mode
[InlineData((byte)0x80, null, AbStatus.BadReply)] // compatibility-mode file missing: unclassified
[InlineData((byte)0x90, null, AbStatus.BadConnection)] // remote node cannot buffer command
[InlineData((byte)0xB0, null, AbStatus.DeviceStateConflict)] // remote node problem due to download
[InlineData((byte)0xF0, (byte)0x06, AbStatus.NotFound)] // address does not point to something usable
[InlineData((byte)0xF0, (byte)0x07, AbStatus.OutOfBounds)] // file is wrong size
[InlineData((byte)0xF0, (byte)0x0A, AbStatus.BadReply)]
[InlineData((byte)0xF0, null, AbStatus.BadReply)]
public void Pccc_status_maps(byte sts, byte? ext, AbStatus expected) =>
AbStatusCodes.FromPccc(sts, ext).ShouldBe(expected);
/// <summary>
/// A dead DH+/DH-485 node behind a bridge answers STS 0x30 (and a busy one 0x90). Neither proves the END
/// device answered, so they are the no-reply comms class, never one of the "answered" refusals — that
/// classification made such a device count as reachable forever (no backoff, no comms-loss watchdog).
/// </summary>
/// <param name="sts">The PCCC STS byte.</param>
[Theory]
[InlineData((byte)0x20)]
[InlineData((byte)0x30)]
[InlineData((byte)0x90)]
public void Pccc_no_reply_statuses_are_never_answered_refusals(byte sts)
{
var status = AbStatusCodes.FromPccc(sts, null);
status.ShouldBe(AbStatus.BadConnection);
status.ShouldNotBeOneOf(AbStatus.NotFound, AbStatus.NotAllowed, AbStatus.OutOfBounds,
AbStatus.DeviceStateConflict, AbStatus.TooLarge, AbStatus.TooSmall, AbStatus.TypeMismatch);
}
[Fact]
public void Ok_is_zero_and_every_error_is_negative()
{
@@ -115,6 +115,30 @@ public sealed class ConnectionManagerServicesTests
ex.Status.ShouldBe(AbStatus.BadConnection); // general 0x01 has no specific mapping
}
/// <summary>
/// Lane K F1 remapped general 0x1C / 0x20 and 0xFF/0x2104 for TAG requests (a per-request refusal). A Forward
/// Open refused with one of them is still a connection failure, exactly as before: the whole connection is
/// unusable, so it must not read as a per-tag refusal.
/// </summary>
/// <param name="hex">The refused Forward Open reply.</param>
[Theory]
[InlineData("DB001C00")]
[InlineData("DB002000")]
[InlineData("DB00FF010421")]
public void Forward_open_refused_with_a_request_level_status_is_still_BadConnection(string hex)
{
var ex = Should.Throw<ForwardOpenRefusedException>(() =>
ConnectionManagerServices.ParseForwardOpenReply(CipReply.Parse(Convert.FromHexString(hex))));
ex.Status.ShouldBe(AbStatus.BadConnection);
}
/// <summary>Positive control: a Forward Open refusal that was already specifically mapped keeps its status.</summary>
[Fact]
public void Forward_open_refused_service_not_supported_stays_Unsupported() =>
Should.Throw<ForwardOpenRefusedException>(() =>
ConnectionManagerServices.ParseForwardOpenReply(CipReply.Parse(Convert.FromHexString("DB000800"))))
.Status.ShouldBe(AbStatus.Unsupported);
[Fact]
public void Refused_forward_open_exception_is_not_aliased_to_the_reply_array()
{
@@ -90,6 +90,32 @@ public sealed class PcccServicesTests
[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));
@@ -297,6 +297,11 @@ public sealed class NativeAbCipTagRuntimeTests
[InlineData("D2000A00", AbStatus.OutOfBounds)]
[InlineData("D2000F00", AbStatus.NotAllowed)]
[InlineData("D200FF010521", AbStatus.OutOfBounds)]
// Lane K F1 (#737): these used to arrive as BadReply, i.e. a comms fault (Degraded + evict) for a tag the
// controller had simply refused. AbCipRefusals.IsDeviceRefusal classifies the new statuses with no driver change.
[InlineData("D2001C00", AbStatus.Unsupported)] // missing attribute list entry data
[InlineData("D2002000", AbStatus.Unsupported)] // invalid parameter
[InlineData("D200FF010421", AbStatus.OutOfBounds)] // offset beyond end of the tag
public async Task A_refused_tag_completes_initialize_and_reports_the_refusal_on_read(string reply, AbStatus expected)
{
var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString(reply)) };
@@ -220,14 +220,30 @@ public sealed class NativeAbLegacyTagRuntimeTests
}
[Fact]
public async Task STS_0x20_on_initialize_is_NotFound()
public async Task STS_0x50_on_initialize_is_NotFound()
{
await using var h = new Harness("slc500", "N99:0").Responder((_, _) => (0x20, null, []));
await using var h = new Harness("slc500", "N99:0").Responder((_, _) => (0x50, null, []));
var ex = await Should.ThrowAsync<AbTransportException>(() => h.Runtime.InitializeAsync(CancellationToken.None));
ex.Status.ShouldBe(AbStatus.NotFound);
h.Runtime.GetStatus().ShouldBe((int)AbStatus.NotFound);
}
/// <summary>
/// Lane K F3 (F8): STS 0x20 / 0x30 come from the host or a bridge when the END device cannot be reached, so they
/// are the no-reply class (BadConnection), not an answered refusal.
/// </summary>
/// <param name="sts">The PCCC STS.</param>
[Theory]
[InlineData((byte)0x20)]
[InlineData((byte)0x30)]
public async Task STS_from_a_bridge_for_an_unreachable_node_is_BadConnection(byte sts)
{
await using var h = new Harness("slc500", "N7:0").Responder((_, _) => (sts, null, []));
var ex = await Should.ThrowAsync<AbTransportException>(() => h.Runtime.InitializeAsync(CancellationToken.None));
ex.Status.ShouldBe(AbStatus.BadConnection);
h.Runtime.GetStatus().ShouldBe((int)AbStatus.BadConnection);
}
[Fact]
public async Task Initialize_performs_exactly_one_read()
{
@@ -305,7 +321,7 @@ public sealed class NativeAbLegacyTagRuntimeTests
public async Task A_failed_second_write_chunk_is_reported_as_failed_not_Good()
{
await using var h = new Harness("slc500", "N7:0", count: 130).Responder((_, n) =>
n == 1 ? (0x30, null, []) : (0x00, null, []));
n == 1 ? (0x60, null, []) : (0x00, null, []));
var ex = await Should.ThrowAsync<AbTransportException>(() => h.Runtime.WriteAsync(CancellationToken.None));
ex.Status.ShouldBe(AbStatus.NotAllowed);
h.Runtime.GetStatus().ShouldBe((int)AbStatus.NotAllowed);
@@ -0,0 +1,91 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// Lane K F3 (AbLegacy F8, review 07 r0 I4), end to end over a real socket with the PRODUCTION
/// <see cref="NativeAbLegacyTagFactory"/>: a bridge (DHRIO / 1761-NET-ENI) in front of a dead DH+ node answers every
/// PCCC request with STS 0x30 "remote node host missing". That is NOT the device answering, so the driver must treat
/// it as a comms fault — Degraded, <c>LastSuccessfulRead</c> not advanced — or the comms-loss watchdog never fires
/// for it. The positive control is a live device refusing a tag (STS 0x60, command protection): per-tag Bad, Healthy,
/// <c>LastSuccessfulRead</c> advanced.
/// </summary>
[Trait("Category", "Unit")]
public sealed class NativePcccBridgeStatusHealthTests
{
private static readonly byte[] FixedId = [0x07, 0x3D, 0xF3, 0x45, 0x43, 0x50, 0x21];
/// <summary>A PCCC fake that answers every request with one fixed STS.</summary>
private static FakeEipServer StsServer(byte sts)
{
var server = new FakeEipServer { ZeroReplyContext = true };
server.Handler = req =>
{
if (FakeEipServer.Command(req) != Ab.Transport.Eip.EncapCommand.SendRRData) return null;
var cip = FakeEipServer.RequestCip(req);
byte[] head = [0xCB, 0x00, 0x00, 0x00, .. FixedId, 0x4F];
return FakeEipServer.RRReply(req, [.. head, sts, cip[15], cip[16]]);
};
return server;
}
#pragma warning disable OTOPCUA0001 // the driver is called directly to pin its OWN classification (no resilience wrap)
private static async Task<AbLegacyDriver> DriverAsync(FakeEipServer server)
{
var host = $"ab://127.0.0.1:{server.Port}/1,0";
var driver = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(host, AbLegacyPlcFamily.Slc500)],
RawTags = AbLegacyRawTags.Entries([new AbLegacyTagDefinition("T", host, "N7:0", AbLegacyDataType.Int)]),
Timeout = TimeSpan.FromSeconds(5),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-pccc-bridge", new NativeAbLegacyTagFactory(new DeviceSessionPool()));
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
return driver;
}
/// <summary>STS 0x30 from a bridge: a comms fault, never evidence the device answered.</summary>
[Fact]
public async Task A_dead_node_behind_a_bridge_degrades_and_does_not_advance_LastSuccessfulRead()
{
var ct = TestContext.Current.CancellationToken;
await using var server = StsServer(0x30);
var driver = await DriverAsync(server);
var before = driver.GetHealth().LastSuccessfulRead;
var result = (await driver.ReadAsync(["T"], ct)).Single();
result.StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Degraded);
health.LastSuccessfulRead.ShouldBe(before);
await driver.ShutdownAsync(ct);
server.HandlerErrors.ShouldBeEmpty();
}
/// <summary>Positive control: STS 0x60 is the live device refusing the tag — answered, so Healthy and advanced.</summary>
[Fact]
public async Task A_live_device_refusing_the_tag_stays_Healthy_and_advances_LastSuccessfulRead()
{
var ct = TestContext.Current.CancellationToken;
await using var server = StsServer(0x60);
var driver = await DriverAsync(server);
var before = driver.GetHealth().LastSuccessfulRead;
var result = (await driver.ReadAsync(["T"], ct)).Single();
result.StatusCode.ShouldBe(AbOpcUaStatusMap.BadNotWritable);
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Healthy);
health.LastSuccessfulRead.ShouldNotBeNull();
health.LastSuccessfulRead.ShouldNotBe(before);
await driver.ShutdownAsync(ct);
server.HandlerErrors.ShouldBeEmpty();
}
#pragma warning restore OTOPCUA0001
}