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
@@ -1,39 +1,81 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
/// <summary>
/// Maps wire statuses (CIP general/extended, PCCC STS/EXT STS) to the transport-neutral <see cref="AbStatus"/> the
/// drivers classify. The class an <see cref="AbStatus"/> lands in is load-bearing for driver health (#527/#737/#738):
/// an ANSWERED refusal (NotFound / NotAllowed / OutOfBounds / TooLarge / TooSmall / TypeMismatch /
/// DeviceStateConflict, and for AbCip Unsupported) proves the device replied; <see cref="AbStatus.BadConnection"/>
/// is the no-reply comms class; <see cref="AbStatus.BadReply"/> / <see cref="AbStatus.Partial"/> are a reply that
/// cannot be classified. Every code not named below is deliberately <see cref="AbStatus.BadReply"/>.
/// </summary>
public static class AbStatusCodes
{
/// <summary>Maps a CIP general status (and optional extended status words) to an <see cref="AbStatus"/>.</summary>
/// <param name="general">The CIP general status byte.</param>
/// <param name="extended">The extended status words, if any.</param>
/// <returns>The mapped status.</returns>
/// <remarks>
/// 0x01 (connection failure, incl. a routing hop's unconnected-send timeout) and 0x07 (connection lost) stay
/// <see cref="AbStatus.BadReply"/> on purpose: a bridge reports a dead END device that way, so they are neither an
/// answer nor proof of no reply (AbLegacy #738 "opaque"). A Forward Open refused with one of the request-level
/// codes (0x1C / 0x20 / 0xFF-0x2104) stays a connection failure — see
/// <c>ConnectionManagerServices.ParseForwardOpenReply</c>.
/// </remarks>
public static AbStatus FromCip(byte general, ReadOnlySpan<ushort> extended) => general switch
{
0x00 or 0x0B => AbStatus.Ok,
0x04 or 0x05 or 0x16 => AbStatus.NotFound,
0x06 => AbStatus.Partial,
0x08 => AbStatus.Unsupported,
0x0A => AbStatus.OutOfBounds,
0x0C or 0x10 => AbStatus.DeviceStateConflict,
0x0E or 0x0F => AbStatus.NotAllowed,
0x13 => AbStatus.TooSmall,
0x15 => AbStatus.TooLarge,
0xFF when extended.Length > 0 && extended[0] == 0x2105 => AbStatus.OutOfBounds,
0xFF when extended.Length > 0 && extended[0] == 0x2107 => AbStatus.TypeMismatch,
0x00 or 0x0B => AbStatus.Ok, // success / already in requested mode
0x04 or 0x05 or 0x16 => AbStatus.NotFound, // path segment error / path unknown / object does not exist
0x06 => AbStatus.Partial, // partial transfer (fragmented read continues)
0x08 => AbStatus.Unsupported, // service not supported
0x0A => AbStatus.OutOfBounds, // attribute list error
0x0C or 0x10 => AbStatus.DeviceStateConflict, // object state conflict / device state conflict
0x0E or 0x0F => AbStatus.NotAllowed, // attribute not settable / privilege violation
0x13 => AbStatus.TooSmall, // not enough data
0x15 => AbStatus.TooLarge, // too much data
// Lane K F1 (#737): the controller answered and refused this one request — not a garbled reply, so not a
// comms fault that degrades the driver and evicts the runtime.
0x1C => AbStatus.Unsupported, // missing attribute list entry data
0x20 => AbStatus.Unsupported, // invalid parameter
0xFF when extended.Length > 0 && extended[0] == 0x2104 => AbStatus.OutOfBounds, // offset beyond end of tag
0xFF when extended.Length > 0 && extended[0] == 0x2105 => AbStatus.OutOfBounds, // beyond end of the object
0xFF when extended.Length > 0 && extended[0] == 0x2107 => AbStatus.TypeMismatch, // data type mismatch
_ => AbStatus.BadReply,
};
/// <summary>
/// True for the CIP statuses Lane K F1 moved from <see cref="AbStatus.BadReply"/> to a request-level refusal.
/// A Forward Open refused with one of them is still a connection failure.
/// </summary>
/// <param name="general">The CIP general status byte.</param>
/// <param name="extended">The extended status words, if any.</param>
/// <returns><see langword="true"/> for 0x1C, 0x20 and 0xFF/0x2104.</returns>
internal static bool IsRequestLevelRefusal(byte general, ReadOnlySpan<ushort> extended) =>
general is 0x1C or 0x20 || (general == 0xFF && extended.Length > 0 && extended[0] == 0x2104);
/// <summary>Maps a PCCC status byte (and optional extended status) to an <see cref="AbStatus"/>.</summary>
/// <param name="sts">The PCCC STS byte.</param>
/// <param name="extSts">The extended status byte, when present.</param>
/// <returns>The mapped status.</returns>
/// <remarks>
/// Per the DF1/PCCC STS table (1770-6.5.16). Lane K F3 (AbLegacy F8, review 07 r0 I4): 0x20, 0x30 and 0x90 are
/// reported by the HOST or a BRIDGE (a DHRIO, a 1761-NET-ENI) when the END device cannot be reached, so they are
/// the no-reply comms class. Mapping them to an answered refusal made a dead DH+/DH-485 node behind a bridge count
/// as reachable forever — no fail-fast backoff, no comms-loss watchdog.
/// </remarks>
public static AbStatus FromPccc(byte sts, byte? extSts) => sts switch
{
0x00 => AbStatus.Ok,
0x10 => AbStatus.Unsupported,
0x20 => AbStatus.NotFound,
0x30 => AbStatus.NotAllowed,
0x40 or 0x50 => AbStatus.DeviceStateConflict,
0xF0 when extSts is 0x06 or 0x07 => AbStatus.NotFound,
_ => AbStatus.BadReply,
0x10 => AbStatus.Unsupported, // illegal command or format
0x20 => AbStatus.BadConnection, // host has a problem and will not communicate
0x30 => AbStatus.BadConnection, // remote node host missing, disconnected or shut down
0x40 => AbStatus.DeviceStateConflict, // host could not complete: hardware fault
0x50 => AbStatus.NotFound, // addressing problem or memory protect rungs
0x60 => AbStatus.NotAllowed, // function not allowed due to command protection
0x70 => AbStatus.DeviceStateConflict, // processor is in Program mode
0x90 => AbStatus.BadConnection, // remote node cannot buffer command
0xB0 => AbStatus.DeviceStateConflict, // remote node problem due to download
0xF0 when extSts is 0x06 => AbStatus.NotFound, // EXT: address does not point to something usable
0xF0 when extSts is 0x07 => AbStatus.OutOfBounds, // EXT: file is wrong size
_ => AbStatus.BadReply, // 0x80, 0xA0, 0xC0, other EXT STS: unclassified
};
}
@@ -85,8 +85,15 @@ public static class ConnectionManagerServices
public static ForwardOpenReply ParseForwardOpenReply(CipReply reply)
{
if (reply.GeneralStatus != 0)
throw new ForwardOpenRefusedException(reply.Status == AbStatus.BadReply ? AbStatus.BadConnection : reply.Status,
reply.GeneralStatus, reply.ExtendedStatus);
{
// An unclassified refusal, or one of the request-level codes AbStatusCodes maps to a per-request refusal
// for TAG services (0x1C / 0x20 / 0xFF-0x2104), is a connection failure here: no tag can be served.
var status = reply.Status == AbStatus.BadReply
|| AbStatusCodes.IsRequestLevelRefusal(reply.GeneralStatus, reply.ExtendedStatus)
? AbStatus.BadConnection
: reply.Status;
throw new ForwardOpenRefusedException(status, reply.GeneralStatus, reply.ExtendedStatus);
}
var r = new CipReader(reply.Data.Span);
var ot = r.U32(); var to = r.U32(); r.U16(); r.U16(); r.U32();
return new ForwardOpenReply(ot, to, r.U32(), r.U32());
@@ -13,10 +13,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// never writes <c>Degraded</c>, never opens the device-wide connect backoff, and warns once per tag. Everything
/// else (timeout, bad connection, a reply that failed to parse, partial, abort, …) is a comms fault.
/// <para>
/// The native transport currently folds CIP general statuses 0x1C, 0x20 and 0xFF/0x2104 into
/// <see cref="AbStatus.BadReply"/> (<c>AbStatusCodes.FromCip</c>, owned by <c>Driver.Ab.Transport</c>), which
/// this classification cannot tell from a garbled reply; until the transport maps them explicitly they stay
/// comms faults.
/// The native transport maps CIP general statuses 0x1C (missing attribute list entry) and 0x20 (invalid
/// parameter) to <see cref="AbStatus.Unsupported"/> and 0xFF/0x2104 (offset beyond end) to
/// <see cref="AbStatus.OutOfBounds"/> (<c>AbStatusCodes.FromCip</c>, Lane K F1), so they classify here as
/// device refusals. A Forward Open refused with one of them stays a connection failure.
/// </para>
/// </remarks>
internal static class AbCipRefusals
@@ -838,10 +838,10 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
/// reachability (backoff reset, <c>LastSuccessfulRead</c>, Healthy).
/// </summary>
/// <remarks>
/// ⚠️ Inherits the native transport's PCCC STS table (<c>AbStatusCodes.FromPccc</c>, Lane K): it maps STS 0x30
/// ("remote node missing" — a dead DH+/DH-485 node behind a bridge) and 0x20 to <c>NotAllowed</c>/<c>NotFound</c>,
/// so on the NATIVE transport such a dead node still reads as answered. Follow-up F8 (review 07 r0 I4); this
/// classification needs no change once it lands.
/// Inherits the native transport's PCCC STS table (<c>AbStatusCodes.FromPccc</c>). Since Lane K F3 (F8, review
/// 07 r0 I4) STS 0x20 / 0x30 / 0x90 — the host or a bridge reporting an unreachable END device — map to
/// <see cref="AbStatus.BadConnection"/> (no reply), so a dead DH+/DH-485 node behind a bridge is a comms fault,
/// not an answer (<c>NativePcccBridgeStatusHealthTests</c>).
/// </remarks>
/// <param name="status">The transport status.</param>
/// <returns><see langword="true"/> for a device refusal.</returns>